merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-08-18 01:33:57 +04:00
176 changed files with 4622 additions and 2565 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Compiled output # Compiled output
/dist /dist
packages/*/dist
/tmp /tmp
/out-tsc /out-tsc
/bazel-out /bazel-out

10
.npmrc Normal file
View File

@@ -0,0 +1,10 @@
# @marketplaces/* packages are published to a private Verdaccio registry on
# the dev server (213.21.246.138:4873), not npmjs. The port isn't open in the
# server firewall (only 80/443/SSH are) - reach it via an SSH tunnel:
# ssh -L 4873:127.0.0.1:4873 seto@213.21.246.138
# See docs/PACKAGE-EXTRACTION.md for the full setup and the follow-up needed
# to expose this properly for CI (reverse proxy + TLS, or open the port).
@marketplaces:registry=http://127.0.0.1:4873/
# Auth token is NOT committed here. Set it locally (npm login --registry=http://127.0.0.1:4873/
# --scope=@marketplaces, or a user-level ~/.npmrc _authToken line) or via CI secret.

View File

@@ -2,6 +2,8 @@
One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational. One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational.
**For what doesn't exist yet:** this doc describes the live surface only. The full set of forward-looking wire contracts for Product Plan v3.1 (money/FX, orders, catalog/offer split, connectors, seller portal, identity, tenant registry, RBAC, analytics — 10 phases + 2 tracks) lives in [docs/backend/](docs/backend/README.md).
Maturity tags used throughout: Maturity tags used throughout:
| Tag | Meaning | | Tag | Meaning |
@@ -243,7 +245,7 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
## 5. Error model ## 5. Error model
**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) derives its error code from **HTTP status only**, ignoring any body field, which is itself a known bug (see below). Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over. **The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over.
### The envelope ### The envelope
@@ -281,8 +283,8 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
| 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. | | 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. |
| 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. | | 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. |
| 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. | | 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. |
| 401 (token expired) | `TOKEN_EXPIRED` | **Known bug, not just a gap:** the client has a dedicated "Session expired" screen wired and ready, but `toAuthErrorShape()` only reaches it via a no-refresh-token-present client-side branch — a *real* backend 401 on `/refresh` always renders the generic "Unauthorized" screen instead, because the mapping function ignores any body code and derives purely from HTTP status. Fix requires the backend to send `error.code: "TOKEN_EXPIRED"` **and** a small frontend change to prefer it. | | 401 (token expired) | `TOKEN_EXPIRED` | **Fixed**`toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. |
| 401 (bad signature) | `INVALID_SIGNATURE` | Same bug class as above — dedicated screen exists, unreachable from a real HTTP response for the identical reason. | | 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. |
**Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated. **Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated.
@@ -371,7 +373,7 @@ WebSessionID: 3f1c2a0e-…
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" } { "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
``` ```
**Payments are frozen** — this call chain is explicitly out of scope for changes; document only, don't modify. **Payments were frozen; unfrozen 2026-08-17** (Sprint 0.1 decision, see `docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`). This call chain is now in scope for the Phase 1 rework specified in `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
--- ---

View File

@@ -6,16 +6,16 @@ Findings only — nothing in this document has been fixed as part of writing it.
## As a Customer / End User ## As a Customer / End User
1. **Ed25519 admin-auth "session expired" and "invalid signature" recovery screens are dead UI.** Both are fully built and wired, but `toAuthErrorShape()` (`core/auth/services/auth.service.ts:110-118`) derives the error code from HTTP status only, never a body-level code — so a real backend 401 always shows the generic "Unauthorized" screen instead. Also an [Engineering](#as-backend--api-engineer) and [Backend](#as-backend--api-engineer) item. 1. **FIXED (verified 2026-08-17).** ~~Ed25519 admin-auth "session expired"/"invalid signature" screens were dead UI~~`toAuthErrorShape()` now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. See `BACKEND-API-REFERENCE.md` §5.
2. **Dark mode selector does nothing.** The light/dark/system dropdown saves correctly, but no CSS anywhere reads the `data-theme-mode` attribute it sets — picking anything but Light changes nothing visually. 2. **FIXED (2026-08-17).** ~~Dark mode selector did nothing~~ — structural dark overrides (bg/text/border/shadow) now wired for all three tenant themes under `[data-theme-mode="dark"]`. Brand colors intentionally unchanged pending a theme-owner-approved dark palette.
3. **"Site Layout" selector (Theme section) has no effect.** `layout.type` is edited but page rendering only ever reads each page's own `layout`, never the top-level selector. 3. **FIXED (verified 2026-08-17).** ~~"Site Layout" selector had no effect~~`SectionEngineService.resolveLayoutType()` now falls back to `bootstrap.layout.type` when a page has no layout of its own.
4. **Footer "Contacts" link has nothing behind it.** No static-page content exists for it at all in the bootstrap data (unlike other footer legal pages, which are populated). 4. **Not a code gap (re-verified 2026-08-17), a content gap.** The mechanism is already fully generic: `features/project-editor/sections/footer-section.component.ts`'s Footer Builder lets an admin create any static page via the CMS and link it into a footer column by `pageKey`, resolved by `FooterResolverService`. "Contacts" just has no authored static page yet on whichever tenant's bootstrap this was checked against — that's a per-tenant content task, not a frontend fix.
5. **Product pages get no per-product SEO.** `SeoService.setItemMeta(item)` — the method that would set per-product Open Graph/canonical tags — exists but is **never called anywhere in the codebase**. Every product page ships only the site-wide default meta tags. 5. **FIXED (verified 2026-08-17).** ~~Product pages got no per-product SEO~~ `SeoService.setItemMeta(item)` is called from `product-details-container.component.ts`.
6. **`og:locale` is hardcoded to `'ru_RU'`** in both SEO meta-tag code paths, regardless of the active locale — a real gap for EN/HY visitors' social-share previews. 6. **FIXED (verified 2026-08-17).** ~~`og:locale` was hardcoded to `ru_RU`~~ — reads `languageService.currentLanguage()` via `OG_LOCALE_MAP` at both call sites.
7. **No structured data (JSON-LD) and no sitemap generation exist anywhere** — confirmed absent, not partially built. Sitemap is backend-only work; JSON-LD would need net-new frontend code. 7. **FIXED (verified 2026-08-17) on JSON-LD; sitemap remains backend-only work.** ~~No structured data (JSON-LD) exists anywhere~~`SeoService.setJsonLd()` injects a real `<script type="application/ld+json">` for `Product` (per-item, via `setItemMeta()`) and `Organization` (site default, via `resetToDefaults()`). No JSON-LD exists yet for `BreadcrumbList` or `ItemList`/category pages — smaller net-new addition if wanted. Sitemap generation is still backend-only, unchanged.
8. **Checkout's payment-description fallback is a hardcoded Russian string** (`'Покупка на Маркетплейсе'`) used as a last resort when no brand name or hostname is available — single-tenant-framed wording in a multi-tenant product. 8. **FIXED (verified 2026-08-17).** ~~Checkout's payment-description fallback was a hardcoded Russian string~~`getPaymentDescription()` (`pages/cart/cart.component.ts:613`) already tries `branding.brandName`, then hostname, and only falls to `i18n.t('cart.paymentDescriptionFallback')` last, translated in all 3 languages (`i18n/{en,ru,hy}.ts`).
9. **Brand color contrast fails WCAG AA.** `--border-color` measures 1.241.42:1 against a 3:1 UI-component requirement in every theme; `--success`/`--warning`/`--error`/`--info-color` fail 4.5:1 when used as plain text. Real palette colors, not a token bug — see [Accessibility](#as-accessibility-reviewer). 9. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ `--border-color` and `--success`/`--warning`/`--error`/`--info-color` darkened, hue-preserving, in all three theme files to clear 3:1 (border, non-text) and 4.5:1 (status colors, plain text). See [Accessibility](#as-accessibility-reviewer).
10. **`stars.component.scss:10` uses a literal hex color** (`#cdd6d5`) with no design token behind it — any future palette change will silently miss this one glyph. 10. **FIXED (verified 2026-08-17).** ~~`stars.component.scss:10` used a literal hex color~~ — now uses `var(--border-color)`.
11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder). 11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder).
--- ---
@@ -50,21 +50,21 @@ Findings only — nothing in this document has been fixed as part of writing it.
See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically: See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically:
1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build. 1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build.
2. **`AdminRole` is defined twice with unrelated shapes** (auth string-union vs. a Users-page display interface) — needs a naming reconciliation before the real role table is built. 2. **FIXED (verified 2026-08-17).** ~~`AdminRole` was defined twice with unrelated shapes~~ — only one `AdminRole` export exists (`core/auth/models/permission.model.ts`); the Users-page shape is `AdminUserRoleRecord` with a disambiguating comment.
3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use. 3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use.
4. **Duplicate search models** exist under two different module paths. 4. **Worse than previously stated (re-verified 2026-08-17): three overlapping `SearchState`-shaped types, not two.** `core/search/models/search.model.ts` is already a clean re-export shim (fixed), but `core/search/models/search-state.model.ts` is a genuine second copy consumed by `catalog-container.component.ts`, and `features/search/facade/search.facade.ts` additionally defines its own private `LegacySearchState` interface with the same fields again. Reconciling all three touches the highest-traffic storefront surface (catalog rendering) — needs its own careful pass with full consumer tracing, not a quick rename.
5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior. 5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior.
6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response. 6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response.
7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client. 7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client.
8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade. 8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade.
9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet. 9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet.
10. **Two per-domain provider tokens have a dead mock branch, silently.** `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` always resolve to the real API implementation regardless of `useMockData` — there is no mock class bound to either token. Anyone toggling mock mode expecting storefront products/categories to mock out will be surprised; only Bootstrap, Backoffice-widget-data, and Admin-Categories actually respect the mock/api switch. 10. **FIXED (verified 2026-08-17).** ~~`PRODUCT_DATA_PROVIDER`/`CATEGORY_REPOSITORY` had a dead mock branch~~ — both tokens' factories now resolve directly to the real API implementation with the dead switch removed, documented inline as intentional.
--- ---
## As Accessibility Reviewer ## As Accessibility Reviewer
1. **Brand color contrast genuinely fails WCAG AA** — see [Product Owner item 9 above](#as-product-owner--business) for the numbers. This requires a theme-owner sign-off before any fix ships, since it changes brand appearance, not just token values. 1. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — see [Product Owner item 9 above](#as-product-owner--business). Applied a hue-preserving darkening of the failing tokens rather than a redesign; a distinct dark-mode-specific status palette (introduced alongside dark mode this session) has not been separately contrast-checked and remains open.
2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end. 2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end.
3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not. 3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not.
@@ -76,7 +76,7 @@ See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract.
2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted. 2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted.
3. **Angular 22 upgrade is researched but not started** (~23.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work. 3. **Angular 22 upgrade is researched but not started** (~23.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work.
4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed. 4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed.
5. **`sellerId` fields are typed as bare `string` instead of the `UUID` alias** used everywhere else in the newer sellers domain — zero functional impact, pure convention drift, cheap to fix opportunistically. 5. **FIXED (verified 2026-08-17).** ~~`sellerId` fields were typed as bare `string`~~`core/sellers/models/seller-scope.model.ts` and all other sellers-domain usages type it `UUID`.
6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed. 6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed.
7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated. 7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated.

View File

@@ -0,0 +1,53 @@
# @marketplaces/auth & @marketplaces/payment — build, version, publish, consume
See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) for why. This doc is the how.
## Current state
Live end to end. `marketplaces` has no local copy of either package — it installs `@marketplaces/auth@0.1.0` from a private Verdaccio registry on the dev server. `packages/` no longer exists in this repo.
## 1. Source repo
[sources.vitanova.network/sdarbinyan/vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git) — single monorepo (npm workspaces), `packages/auth` + `packages/payment`, `main` branch. This is where the package source lives and where CI publishes from.
## 2. Registry
Verdaccio, running in Docker on the dev server (`213.21.246.138`, container `verdaccio`, config/storage at `/srv/marketplaces/verdaccio/`). **Not publicly reachable** — the server firewall only allows 80/443/SSH, and opening 4873 or loosening the registry's `$authenticated` access policy are both security-relevant changes that need an explicit decision, not something done silently. Reach it today via SSH tunnel:
```bash
ssh -L 4873:127.0.0.1:4873 seto@213.21.246.138
```
Follow-up decision needed before CI can publish/consume without a human at the keyboard: either (a) reverse-proxy `/verdaccio/` or a subdomain through the existing nginx on 443 with TLS, or (b) open 4873 directly (not recommended — plain HTTP with credentials). Neither is done yet.
`@marketplaces/*` packages require an authenticated user to install (`access: $authenticated` in Verdaccio config) — deliberately not `$all`, since loosening that is itself a security-relevant config change. A registry user `marketplaces-ci` exists; get a token via `npm login --registry=http://127.0.0.1:4873/` (through the tunnel) and set it locally as a user-level `~/.npmrc` `_authToken` line, or export `NPM_TOKEN` and append it to `.npmrc` at CI runtime — never commit a token into this repo's `.npmrc`.
`marketplaces/.npmrc` maps the scope: `@marketplaces:registry=http://127.0.0.1:4873/` — update this once the registry has a real public/internal address.
## 3. Versioning
[Changesets](https://github.com/changesets/changesets) — built for "many packages, one repo, independent versions." A PR that changes `packages/auth` adds a changeset file (`npx changeset` from the vitanovaPackages repo root, picks package + bump type + writes a short description) alongside the code change.
## 4. Publishing (CI)
`vitanovaPackages/.github/workflows/release.yml`: on push to `main`, installs, builds, tests, then `changesets/action` — opens/updates a version-bump PR if unreleased changesets exist, publishes once that PR merges. Needs `NPM_TOKEN` (Verdaccio token) and `GITHUB_TOKEN` as repo secrets; also needs CI to reach the registry, which circles back to §2's open follow-up. Until that's resolved, publish manually the same way this session did it: build (`tsc`), `npm publish --registry http://127.0.0.1:4873/` through the tunnel.
## 5. Consuming from `marketplaces` (and other projects)
```bash
npm install @marketplaces/auth
```
```ts
import { AuthService, AdminAuthService, adminAuthGuard, ... } from '@marketplaces/auth';
```
Pinned to an exact version (`"0.1.0"`, no `^`/`~`) per ADR-0001's consequence about registry-outage blast radius — bump deliberately, not automatically.
[renovate.json](../renovate.json) at repo root opens a grouped PR whenever either package publishes a new version — review and merge it manually (`automerge: false`).
## 6. Migration cutover
**Auth: done.** `@marketplaces/auth@0.1.0` holds the real implementation — two independent modules, `telegram/` (live Telegram QR/session auth, customer + admin) and `ed25519/` (future challenge/response admin auth, backend not shipped). Environment coupling was replaced with `AUTH_API_URL`/`TELEGRAM_BOT_USERNAME` injection tokens, provided from `app.config.ts`; `environment.production` became Angular's `isDevMode()`. `AdminPermissionsService` and `requireAdminPermission` stayed in `marketplaces` (`core/admin-auth/`) since they read this app's mock Users domain, not a portable auth concern. All ~30 call sites import `@marketplaces/auth`. `npm run build`, `npm run arch:check:boundaries`, and `npm test` (103/103) all pass against the registry-installed package.
**Payment: not started.** `core/finance`/`core/pricing` still live in `marketplaces`, same process as above once prioritized. `@marketplaces/payment@0.1.0` is published (scaffold only) but not yet a `marketplaces` dependency.

View File

@@ -0,0 +1,462 @@
# Product Plan v3.1 — Delivery Plan (Phases → Sprints → Todos)
Companion to [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Every gap identified there is assigned here exactly once. Wire contracts for every `[BE]`/`[BOTH]` phase and track below are written up in [docs/backend/](backend/README.md) — hand that directory to whoever builds the backend.
**No calendar dates.** The plan itself (§12) refuses invented dates and fixes *sequence + exit criteria* instead. This document does the same. Sprints are ordered units of work, not two-week promises. Sizes are relative: **S** / **M** / **L** / **XL**.
**Ownership tags:** `[FE]` this repo · `[BE]` backend/platform service · `[BOTH]` coordinated contract change · `[DEC]` decision, no code.
**Deviation from the plan's own order, and why:** the plan sequences P0-C (external ingestion) before P0-D (catalog integrity). We swap them. External order ingestion maps `externalSKU → internal offer` (§5.1), and `Offer` does not exist yet — ingestion has nothing to map onto until the Product/Offer split ships. Everything else follows the plan's ordering.
---
## Phase map
| Phase | Name | Plan ref | Gate |
|---|---|---|---|
| **0** | Unblock & seams | — | Decisions answered; every admin domain swappable |
| **1** | Money & payment truth | P0-A, §2.3 §3.3 §3.8 §7 | An order total is explainable from data |
| **2** | Orders canonical + notifications | P0-B, §2.8 §2.10 §3.5 | Paid order appears and notifies without refresh |
| **3** | Catalog integrity + fulfillment | P0-D, §2.1 §2.4 §3.6 | Any published offer is genuinely buyable and fulfillable |
| **4** | External order ingestion | P0-C, §5 §3.7 | External purchase lands in Orders, no duplicates |
| **🚦** | **PRODUCTION LAUNCH GATE** | §3 LAUNCH BLOCKERS, §13.2 | All P0 closed and evidenced |
| **5** | Seller Portal | P1-A, §2.2 | Seller runs own offers and orders in scoped UI |
| **6** | Server cart + checkout session | P1-B, §2.5 §2.6 | Client price never trusted; repeat-safe |
| **7** | Payments hardening + reconciliation | P1-C, §2.7 §7.3 | Internal vs provider matched, mismatches visible |
| **8** | Identity & messaging | §2.9 §3.4 §14 | VK/MAX/Telegram linked; bot collects delivery |
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
---
## Phase 0 — Unblock & seams
Nothing downstream can be honestly estimated until this closes. Two sprints: one is other people answering questions, one is work we can do today with no answers.
### Sprint 0.1 — Decisions `[DEC]`
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
- [x] **Backend ownership.** *Still open — user flagged the question itself as unclear on first pass; re-ask in plain terms before Phase 1 implementation starts (not just contract-writing).* Nothing downstream is blocked by this being open — the Phase 1 backend contract doc exists regardless of who builds against it.
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed once backend ownership is confirmed.
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
- [x] **Multi-seller orders — unified**, judgment call as instructed. One `Order` per checkout regardless of seller count, split into per-seller `Fulfillment` groups internally (matches §2.8's "canonical Order regardless of source" and §2.5's cart-level seller-grouping requirement without introducing parallel parent orders). Applies to Phase 3's `Offer` model, Phase 5's Seller Portal order view (scoped to that seller's fulfillment groups within the shared order), and closes the three-document disagreement flagged in Z16.
- [x] **"Fixed 5-second payment" claim — resolved as a non-issue.** User: "make polling 5 secs." Checked `config/constants.ts`: `PAYMENT_POLL_INTERVAL_MS` is already `5000`. This is a poll *cadence* against real provider status each tick, not an artificial fixed-delay-then-success — stays compliant with the plan's §3.2 prohibition. No code change needed; confirmed and left as-is.
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
- [x] **Document version — v3.1 is canonical.** The source file's internal "3.0" version block is stale/wrong; all our docs treat v3.1 as authoritative going forward.
**Exit:** all nine answered in writing.
### Sprint 0.2 — Seams and type reconciliation `[FE]` — runs regardless of answers
- [ ] Add DI tokens to the 9 admin domains that have none: Orders, Products, Users, Transactions, Monitoring, Moderation (+ derived Customers, Analytics). **M** — hard prerequisite for every `[BE]` swap in Phases 17.
- [ ] Reconcile `AdminRole` — defined twice with unrelated shapes (auth string-union vs. Users-page display interface). **S**
- [ ] Reconcile the two `Category` types, both fed by the same `/category` response, both in use. **S**
- [ ] Resolve `SellerConfig` (bootstrap) vs. `Seller`/`SellerBranding` (domain) — pick one or document the mapping. Blocks Phase 5. **S**
- [ ] Build the feature-flag / capability-guard service an existing ADR already promises; migrate the hand-rolled `sellerManagement.enabled` check onto it. **S**
- [ ] Build the centralized error-handling layer (`core/error-handling/`, `core/interceptors/` are `.gitkeep`-only today): error-envelope interceptor + 429 handling. **M** `[BOTH]` — envelope shape needs backend agreement.
- [ ] Fix `toAuthErrorShape()` to read a body-level code, not HTTP status alone — the built "session expired" / "invalid signature" screens are currently dead UI. **S**
- [ ] Bind mock implementations to `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY`, or delete the dead mock branch. Today both silently ignore `useMockData`. **S**
**Exit:** any admin domain can be pointed at a real backend by swapping one provider.
---
## Phase 1 — Money & payment truth (P0-A)
Closes §3.3 and §3.8, and half of the §13.1 acceptance table. The single highest-value phase: it is what makes totals explainable to a bank.
### Sprint 1.1 — Money model `[BOTH]`
- [ ] `Money = { amountMinor: int, currency }` end to end. Kill float arithmetic in `CurrencyRatesService.convert()`. **L**
- [ ] Currency minor-units + rounding rules table (RUB/USD/EUR/AMD at minimum). **M**
- [ ] Delete browser-owned rates: remove `currencyRates.v1` from `localStorage` and the hardcoded `DEFAULT_RATES` fallbacks (`USD: 0.011`, `AMD: 4.3`). **S**
- [ ] Remove the admin-typed rate editor from Admin Settings once a real source exists. **S**
### Sprint 1.2 — FX quote + rate source `[BE]` + `[FE]`
- [ ] `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` entity + endpoint. **M**
- [ ] Rate-source adapter behind an interface; concrete provider pluggable (§7.1). **M**
- [ ] Stale/outlier quote rules; checkout **blocks** or uses an explicitly configured fallback. **M**
- [ ] `PriceBook`: offer base currency + allowed display/checkout currencies per tenant. **M**
### Sprint 1.3 — Price snapshot + server-authoritative amount `[BOTH]` — needs the freeze lifted
- [ ] `PriceSnapshot { offerId, amount, currency, fxQuoteId, capturedAt }`, immutable. **L**
- [ ] Server computes and validates the charged amount. Stop trusting `CartPaymentRequest.amount` and the per-item `price[]` array from the browser. **L** — the plan's §2.5 headline requirement.
- [ ] Old orders never recalculated when a rate updates. **S**
- [ ] Backoffice "total formula" panel: lines × qty discounts + delivery + fees, plus the FX quote used (§7.2). **M**
- [ ] `PriceHistory` on offer price and stock, with author/source (§2.1). **M**
### Sprint 1.4 — Payment timeline `[BE]` + `[FE]`
- [ ] Explicit state machines: `PaymentIntent` (created→pending→authorized/paid→failed/cancelled), `Payment` (received→confirmed→captured/settled→refunded), `Order` (pending_payment→paid→processing→fulfilled). **L**
- [ ] Persist `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` per transition. **M**
- [ ] Webhook entrypoint with signature verification + idempotency (§2.7). **L**
- [ ] Idempotency keys on checkout, payment and order creation. Zero `idempot*` exists today. **M**
- [ ] Replace client-polled status signals with server truth; keep polling only as a UI fallback. **M**
- [ ] Keep the current honest behaviour: no artificial delay. Already compliant — protect it with a test. **S**
**Exit criteria (plan's own):** currency converts correctly; payment timeline reconstructable from provider events; every total explainable from `SKU/qty/delivery/discount/FX`.
---
## Phase 2 — Orders canonical + notifications (P0-B)
### Sprint 2.1 — Canonical order model `[BOTH]`
- [ ] `Order` header: `marketplaceId, source, customer, currency, subtotal, discounts, delivery, total, paymentStatus, orderStatus`. **L**
- [ ] `OrderLine` with `offerId, sellerId, skuSnapshot, titleSnapshot, qty, unitPriceMinor, lineTotalMinor, priceSnapshotId`. **M**
- [ ] `OrderEvent` timeline: created, paid, seller notified, accepted, fulfilled, cancelled, refunded (§2.8). Closes our own "Real order audit trail" TODO. **M**
- [ ] Real `AdminOrdersApiGateway` replacing the 24-row static seed with no create path. **L** `[BE]`
- [ ] Admin order actions: assign, resend notification, replay sync, cancel/refund by permission, comment, export. **M**
- [ ] `OrderContactSnapshot` — name/contacts frozen at order time, immune to later profile edits (§2.9). **S**
### Sprint 2.2 — Event bus + Notification Center `[BE]` + `[FE]`
- [ ] Platform event bus emitting `order.created`, `order.paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested/completed`, `external_order.imported`. **L**
- [ ] `Notification` entity: `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link**. **M**
- [ ] `DeliveryAttempt` log per external channel — a Telegram/email failure must never lose the internal notification (§2.10). **M**
- [ ] Backoffice Notifications section: unread queue, incidents, filter by marketplace and event type. Missing entirely from our nav today. **M**
- [ ] Repoint `AdminOrderWatcherService` from polling to the event stream. Feature is already built and inert — this is what switches it on. **S**
**Exit:** a paid order appears in backoffice without manual refresh, with deep link and seller/source.
---
## Phase 3 — Catalog integrity + fulfillment (P0-D)
Biggest structural change in the whole programme. Everything about multi-seller commerce hangs off it.
### Sprint 3.1 — Product / Offer split `[BOTH]`
- [ ] Introduce `Offer/Listing { id, marketplaceId, sellerId, variantId, sellerSku, priceMinor, currency, stockPolicy, status, publishedAt }`. **XL** — does not exist in any form today.
- [ ] Move price, stock, currency and status off `Product` onto `Offer`. **L**
- [ ] Formalise `Product` / `Variant` / `SKU` / `Category` (with `attributesSchema`, SEO) as content-only. **L**
- [ ] Unify the admin mock product domain with the live storefront `Item` domain — two unrelated shapes today. **L**
- [ ] Offer lookup in backoffice by internal SKU, seller SKU, product ID or external mapping (§2.1 "готово, когда"). **M**
### Sprint 3.2 — Lifecycle, import, inventory `[BOTH]`
- [ ] `draft → moderation → published → paused/archived` for both product and offer; wire the existing mock Moderation module to it. **M**
- [ ] Bulk import CSV/API: required-field validation, **error preview before apply**. Nothing exists (current "bulk" is Admin Categories edit actions only). **L**
- [ ] `InventoryRecord`: `available` / `reserved` / `sold` counted separately. **L**
- [ ] Reservations at checkout or pre-payment per strategy, with TTL. **M**
- [ ] Idempotent upsert for seller feed stock updates; repeat webhook must not double-decrement. **M**
- [ ] Oversell → dedicated incident queue, never silently hidden (§2.4). **M**
### Sprint 3.3 — Fulfillment + executability `[BOTH]`
- [ ] `Fulfillment` entity: manual / warehouse / pickup / digital; `status, assignedTo, issuedAt/shippedAt`, evidence where applicable. One `fulfil*` reference exists in the entire codebase today. **L**
- [ ] Publish-time executability validation — an offer that cannot actually be fulfilled cannot be published (§3.6). **M**
- [ ] Explicit test proving there is **no** inspector-detection branch anywhere: same production flow for every buyer (§3.6, §10.2, §13.2 last item). **S**
- [ ] Multi-seller cart grouping by seller and fulfillment rules — currently undefined behaviour (§2.5). **M****Sprint 0.1 decision (2026-08-17): unified.** One `Order` per checkout regardless of seller count; group lines into per-seller `Fulfillment` entries internally, no parallel parent orders.
**Exit:** any published, available offer really passes order → fulfillment.
---
## Phase 4 — External order ingestion (P0-C)
Zero percent built today. **Sprint 0.1 decision (2026-08-17): no fixed marketplace list** — connectors onboard "our new ones, partners, new, etc." as they arrive, not a pre-named enumeration. Sprint 4.2 is retired as originally written ("one sprint per named marketplace") and replaced with a generic onboarding runbook — Sprint 4.1's framework is now the deliverable that matters, sized to be genuinely config-driven rather than one-off per provider.
### Sprint 4.1 — Connector framework `[BE]`
- [ ] `Connector` + `ConnectorCredentialRef` in secret storage, scoped per marketplace/seller. **M**
- [ ] Inbound: webhook where the provider supports it, polling fallback with cursor/since. **L**
- [ ] `RawExternalEvent` — persist the raw payload before parsing, for traceability. **S**
- [ ] Normalizer: external payload → canonical `ExternalOrderEvent` → internal `Order`. **L**
- [ ] `ExternalOrderMapping`: `externalSellerId / externalProductId / externalSKU → internal seller/offer`. **L**
- [ ] Idempotency on `source + externalOrderId/eventId`; a repeat must not create a duplicate order. **M**
- [ ] Exponential retry, `DeadLetter`, manual replay from backoffice. **M**
- [ ] **Unmatched queue** for events with no SKU mapping. **M**
- [ ] Status/fulfillment push back to the external marketplace where its API allows (§5.2 step 8). **M**
- [ ] **Config-driven adapter contract** — a new partner connector is authored as configuration (auth type, field mapping, rate limits) against the Sprint 4.1 framework, not a bespoke integration each time. **L** — this is what "no fixed list" requires structurally.
### Sprint 4.2 — Connector onboarding runbook `[BE]` — repeats per new partner, no longer named up front
- [ ] Generic onboarding checklist against the Sprint 4.1 framework: auth, endpoint mapping, rate limits, sandbox verification. **M each**, sized down from **L** now that the framework absorbs the bespoke work.
### Sprint 4.3 — Connector observability `[FE]` + `[BE]`
- [ ] Backoffice **Integrations** section (missing from our nav): connectors, payment providers, FX sources, messaging. **M**
- [ ] Per-connector health: last success, lag, errors, rate limit, backlog, unmatched mapping. **M**
- [ ] Trace id on every connector error, visible in backoffice (§5.2 SLA). **S**
- [ ] SLA instrumentation: webhook 99% under 60s; polling ≤ interval + 60s; **0** duplicate orders. **M**
**Exit:** an external purchase creates/updates an order automatically, never duplicates, and notifies the responsible manager.
---
## 🚦 PRODUCTION LAUNCH GATE
Per §3 "LAUNCH BLOCKERS" and the §13.2 checklist. Do not schedule a launch before every line is green **and evidenced by a test, not an assertion**.
- [ ] All P0 closed and confirmed by tests
- [ ] Production analytics collecting real events (Track A)
- [ ] Catalog contains only genuinely available/publishable offers
- [ ] Seller permissions verified (Phase 5 or enforced-empty)
- [ ] cart → checkout → payment → order end-to-end smoke passed
- [ ] Webhook signatures, idempotency, retry verified
- [ ] External connector reconciliation passed
- [ ] FX source live, stale-quote policy verified
- [ ] Notification delivery + fallback verified
- [ ] Refund flow + reconciliation smoke passed
- [ ] Domains/SSL/health checks green (Phase 9)
- [ ] Backup/rollback exists
- [ ] Audit enabled (Track S)
- [ ] **No branch anywhere alters commerce flow based on who the buyer appears to be**
---
## Phase 5 — Seller Portal (P1-A)
A placeholder page with a `false` flag and zero backend bytes today. Note: the enabled code path has **never been exercised even once** — every prior verification ran with the flag at its real value.
### Sprint 5.1 — Seller foundation `[BOTH]`
- [ ] `SellerOrganization`, `SellerUser`, `SellerMarketplaceMembership`, `SellerIntegration`. **L**
- [ ] Onboarding: organisation, credentials/profile, contacts, marketplace applications, moderation status. **L**
- [ ] Backoffice **Sellers** section (missing from nav): organisations, applications, roles, status, listings, integration health. **L**
### Sprint 5.2 — Seller working surfaces `[FE]` + `[BE]`
- [ ] Catalog: create/edit products & offers, media, attributes, submit for moderation, bulk import. **L**
- [ ] Prices & Stock: mass edit, API/feed sync, change history, sync errors. **L**
- [ ] Orders: new, confirm, pick/issue/ship, cancel, return, SLA, comments. Per the unified-orders decision (Sprint 0.1), this view is scoped to *this seller's* `Fulfillment` group within each shared `Order`, not a separate seller-owned order. **L**
- [ ] Finance: accruals, commissions, refunds, settlement/payout register, report export. **L**
- [ ] Team: `SELLER_OWNER`, `SELLER_CATALOG_MANAGER`, `SELLER_ORDER_MANAGER`, `SELLER_FINANCE_VIEWER`, `SELLER_VIEWER`. **M**
- [ ] Integrations: API credentials, webhook/feed status, external SKU mapping, sync logs. **M**
### Sprint 5.3 — Seller isolation `[BE]` + `[Q]`
- [ ] A seller cannot see another seller's products, orders, customers, finance or API keys — enforced backend-side, tested. **M**
- [ ] Bank/payment detail changes: step-up auth + audit event + approval when maker/checker is on. **M**
- [ ] Seller staff permissions verified backend-side regardless of UI visibility. **M**
- [ ] First-ever fixture test of the seller-management enabled state. **S**
---
## Phase 6 — Server cart + checkout session (P1-B)
Partly pulled forward into Sprint 1.3 (server-authoritative amount). This phase completes the move.
### Sprint 6.1 — Server cart `[BOTH]`
- [ ] `Cart` / `CartLine` server-side, keyed on `offerId`. Replaces `localStorage` + Telegram CloudStorage. **L**
- [ ] Idempotent add/update/remove; quantity validated against stock and seller rules. **M**
- [ ] Price-refresh: cart surfaces price changes before checkout and requires explicit confirmation when the total moved. **M**
- [ ] Guest cart via session token; authenticated cart bound to customer account. **M**
- [ ] Expiration: inactive carts cleared, reservations released on TTL. **S**
### Sprint 6.2 — Checkout session `[BOTH]`
- [ ] `CheckoutSession` entity. `features/website/checkout/` is an empty directory today; checkout lives in a 751-line cart popup. **XL**
- [ ] Server re-validates offers and stock at checkout start. **M**
- [ ] Contact requirements enforced by tenant policy: email and/or phone verifiable (§2.6 step 4). **M**
- [ ] Clear total breakdown shown to the customer. **M**
- [ ] `PaymentIntent` via provider adapter; repeat click must not create a second intent. **M**
- [ ] Guest-checkout on/off per tenant policy (§6.2). **S**
- [ ] `DeliveryOption` entity. **M**
---
## Phase 7 — Payments hardening + reconciliation (P1-C)
### Sprint 7.1 — Refunds `[BOTH]`
- [ ] `Refund` as a first-class operation with reason, actor and order-line linkage. `requestRefund(id)` is a mock method today. **L**
- [ ] Partial refunds; `refunded / partially_refunded` states. **M**
### Sprint 7.2 — Reconciliation `[BE]` + `[FE]`
- [ ] `ReconciliationRecord`; match on `providerPaymentId` / merchant reference / amount+currency fallback (§7.3). **L** — zero `reconcil*` in the codebase today.
- [ ] Classify: unmatched, duplicate, amount mismatch, status mismatch. **M**
- [ ] Backoffice **Payments & Finance** section (missing from nav): payments, refunds, reconciliation queue, unmatched events, settlements. **L**
- [ ] Controlled resolution with full audit trail. **M**
- [ ] Settlements / payout register. **L** — zero `settlement*` today.
### Sprint 7.3 — Provider breadth `[DEC]` + `[BOTH]`
- [ ] Decide additional providers beyond the current QR/card flow (wallets, BNPL) — open business question. **DEC**
- [ ] Provider adapter interface so a new provider is a plug-in, not a rewrite. **M**
---
## Phase 8 — Identity & messaging (§2.9, §3.4, §14)
**Sprint 0.1 decision (2026-08-17): VK ID first, then everything else** ("do all after vk"). Order below is resequenced accordingly — VK ID moved ahead of OTP.
### Sprint 8.1 — Customer identity core `[BOTH]`
- [ ] `Customer`, `ExternalIdentity`, `ContactMethod`, `Verification`, `Consent`. **L**
- [ ] Telegram demoted from sole identity to one provider among several. **M**
- [ ] `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt`. **S**
- [ ] Backoffice **Customers** on real data: profiles, verified contacts, orders, consent. **M**
- [ ] Sensitive profile changes logged. **S**
### Sprint 8.2 — VK ID `[BOTH]` — new in v3.1, now first per Sprint 0.1
- [ ] OAuth 2.1/PKCE completed **backend-side**; link external identity to `Customer`. **L**
- [ ] VK ID as the primary storefront social login. **M**
- [ ] Repeat login must never create a duplicate customer. **M**
- [ ] Identity-conflict handling → controlled resolution, never overwrite an existing binding (§14.3). **M**
### Sprint 8.3 — Email/phone OTP `[BOTH]` — after VK ID
- [ ] Implement the approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). **L**
- [ ] Position it as recovery/fallback per v3.1 §14, not as the primary path. **S**
### Sprint 8.4 — MAX + Telegram bot channels `[BOTH]` — new in v3.1
- [ ] `ContactChannel`, `BotConversationBinding`, `MessagingConsent`. **L**
- [ ] MAX bot-assisted linking: one-time code, TTL, single-use, bound to marketplace + browser session. **L**
- [ ] Provider secrets never reach the frontend; all bot updates handled idempotently. **M**
- [ ] Bot adapters (VK / MAX / Telegram) normalised into one `MessagingEvent` keyed to `orderId`. **L**
### Sprint 8.5 — Notification Orchestrator + delivery conversation `[BE]` — new in v3.1
- [ ] Orchestrator routes `order.paid` to the customer's chosen channel; the backoffice notification always fires regardless. **L**
- [ ] Channel choice in checkout ("where should we send confirmation?"), recorded in `OrderContactSnapshot`; linking flow must not lose the cart or checkout session. **M**
- [ ] Delivery Conversation State Machine: `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`. **L**
- [ ] Bot collects city/address/recipient/phone/time window/comment; backend validates and snapshots into the order. **L**
- [ ] **The bot must never change financial statuses** — delivery fields only, via Delivery Service. **M**
- [ ] Follow-up rules per tenant; after N attempts hand off to a manager, no infinite spam. **M**
- [ ] Manager handoff view: message history, current conversation state, accept handoff. **M**
- [ ] Messenger unavailability creates a `DeliveryAttempt` error and triggers fallback — never blocks the order. **M**
---
## Phase 9 — Tenant registry, domains, releases (P2-A)
### Sprint 9.1 — Marketplace Registry `[BOTH]`
- [ ] `Marketplace`, `MarketplaceDomain`, `MarketplaceFeatureSet`, `MarketplaceRevision`. **L**
- [ ] Backoffice **Marketplaces** section (missing from nav): registry, type, status, domains, currencies, feature set, responsible manager. **L**
- [ ] Onboarding wizard, all 8 steps of §4.3 (card → feature set → domains → design → roles → integrations → staging + smoke → production launch). **XL**
- [ ] Lifecycle state machine `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, **showing which blocker prevents the next transition**. **L**
- [ ] Marketplace dashboard (§4.2): GMV, paid orders, conversion, payment failure rate, orders needing action, seller moderation queue, low stock, unmatched events, integration health, domain/SSL/release status. **L**
- [ ] Re-scope the [super-admin Phase 1 design](superpowers/specs/superuser.md) against this — it overlaps registry and audit. **M**
- [ ] Consolidate `MarketplaceRef` vs. `TenantConfig` if a third marketplace-shaped type appears. **S**
### Sprint 9.2 — Domain automation `[BE]`
- [ ] Hostinger DNS integration, all 7 endpoints from §8.2. Zero references exist today. **L**
- [ ] Read current zone → snapshot/rollback payload → build and validate plan → apply only after production approval. **L**
- [ ] **Never touch MX/SPF/DKIM/DMARC/CAA** without a separate task. **S**
- [ ] Propagation, SSL and health verification; mark domain active only after checks pass. **M**
- [ ] Backoffice **Domains & Releases** section (missing from nav). **M**
### Sprint 9.3 — Publish model `[BOTH]`
- [ ] `draft → validation → preview → publish` with immutable published revisions; rollback creates a new revision (§8.3). **L**
- [ ] Real builder persistence — today `apiEndpoints.builder` is an empty placeholder and "publish" only promotes a `localStorage` signal. **L**
- [ ] CMS/static pages get a real backend write path (currently in-memory bootstrap only). **L**
- [ ] Enforce that orders/payments/inventory ledger are **not** part of a content revision and never roll back with the storefront. **S**
- [ ] Tenant resolution hardening: verified Host server-side, unknown Host → 404 with **no fallback tenant** (§6.1). **M**
---
## Phase 10 — Tenant content modules (P2-B, Gorbushka)
Only after Commerce Core is real. The plan is explicit that Gorbushka does not define the architecture.
### Sprint 10.1 — Directory content entities `[BOTH]`
- [ ] `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings`. Only static pages exist today. **XL**
- [ ] Every entity carries `marketplaceId`, audit, and publish/preview flow. **M**
- [ ] Mall scheme / floors / pins UI. **L**
- [ ] Rent listings + lead capture. **M**
### Sprint 10.2 — Gorbushka tenant config `[FE]`
- [ ] Feature set per §11.1: CMS, shops, services, scheme, rent, news, SEO/media/domains **on**; catalog / seller portal / commerce **platform-ready but off**. **M**
- [ ] Prove commerce can be switched on later without touching backend or storefront code. **M**
---
## Parallel tracks
### Track A — Analytics pipeline (P1-D, §3.1 §6.3)
**Start at Phase 1, not last.** Longest lead time in the programme, and it is a P0 in the plan's own §3. There is no tracking infrastructure at all today — this is not a missing endpoint.
- [ ] **A1** Server-side event logging spine. **XL** `[BE]`
- [ ] **A2** Traffic events: `session_started`, `page_view`, source/utm/referrer, unique users/sessions. **M**
- [ ] **A3** Catalog events: `search`, `category_view`, `product_view`, `seller_view`. **M**
- [ ] **A4** Commerce events: `add_to_cart`, `cart_view`, `checkout_started`, `payment_started`, `payment_success/failed`, `order_created`. **M**
- [ ] **A5** Operations metrics: `order_paid_to_notification` latency, fulfillment time, connector lag, payment webhook lag. **M**
- [ ] **A6** Quality metrics: frontend/backend errors, checkout validation failures, FX stale-rate blocks. **M**
- [ ] **A7** Real funnel dashboard in backoffice, replacing the mock-composed Analytics facade. **L**
- [ ] **A8** **Synthetic traffic technically separated** from production analytics — staging/test only, never presented as real visits (§3.1, §6.3). **M**
- [ ] **A9** Real product view counts — the shipped "Views" column always renders `0`. Either bridge to the live storefront `Item.visits` or serve it from the real Products backend. **S**
- [ ] **A10** Post-launch monitoring set (§13.3): checkout conversion, payment success/failure, webhook lag, order-notification lag, connector lag, FX quote age, unmatched reconciliation, stuck fulfillment. **L**
- [ ] **A11** Trending search terms endpoint — `loadTrending()` is a stub returning `of(null)`. **S**
### Track S — Security, RBAC, audit (§4.4, §10)
**Gate on Phase 5 and on the launch gate.** Today the role model is decorative: types exist, nothing gates any button, page or action. Anyone who authenticates has full access.
- [ ] **S1** Enforce RBAC backend-side with tenant scope on every request. **L**
- [ ] **S2** Implement the 17 roles across 3 scopes (5 platform / 7 marketplace / 5 seller). **L**
- [ ] **S3** Frontend permission guards on routes and actions — currently zero. **M**
- [ ] **S4** Audit log covering permissions, seller changes, catalog moderation, price, payment/refund, manual order actions, integrations, production launch. `audit` appears only as mock display fields today. **L**
- [ ] **S5** Backoffice **Audit & Security** section (missing from nav): role changes, sensitive actions, login/security events, exports. **M**
- [ ] **S6** Step-up authentication for sensitive financial actions. **M**
- [ ] **S7** Rate limits and abuse controls on storefront/auth/provider endpoints; client-side 429 handling (zero today). **M**
- [ ] **S8** Secret storage for provider/connector credentials, scoped per marketplace/seller. **M**
- [ ] **S9** PII minimisation: store only necessary customer data, restrict access and export. **M**
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
### Track Q — QA & E2E (§13)
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
- [ ] **Q1** Stand up an E2E harness (Playwright or equivalent) — none exists. **L**
- [ ] **Q2** Solve automated admin login; several past "verified live" claims were code-inspection only because `/edit` and `/backoffice` need Telegram login. **M**
- [ ] **Q3** E2E: full §13.1 acceptance path — seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment. **XL**
- [ ] **Q4** E2E: currency switch recalculates by FX quote — explicitly, `160 RUB` must not become `160 USD/AMD`. **M**
- [ ] **Q5** E2E: repeat webhook and double-click create exactly one order. **M**
- [ ] **Q6** E2E: external marketplace purchase imports and notifies. **M**
- [ ] **Q7** Facade tests for cart/checkout, moderation, Orders, Products, Users, Transactions, Monitoring — the domains about to get real backends carry the most regression risk with the least coverage. **L**
- [ ] **Q8** Regression pattern for reactive flag/config reads that must track `bootstrapRevision()` — this bug class already bit us once and was invisible until specifically hunted. **S**
- [ ] **Q9** Set a justified coverage floor and a CI gate. Deliberately unset today. **M**
- [ ] **Q10** One real screen-reader pass (NVDA/VoiceOver). Never performed on this codebase — every accessibility claim to date is automated tree inspection only. **M**
### Track N — API namespace migration (§9.3)
Cheapest now, more expensive every phase. Decision in Sprint 0.1.
- [ ] **N1** Adopt `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`. **L** `[BOTH]`
- [ ] **N2** Migrate today's flat unversioned endpoints (`/cart`, `/orders`, `/items`, `/category`, `/searchitems`) plus the separate `qrApiUrl` host. **L**
- [ ] **N3** Agree the structured error envelope; today no interceptor reads error bodies at all. **M** (implementation lands in Sprint 0.2)
### Track Z — Pre-existing repo debt
Not in the plan, but real. Fold into whichever phase touches the same surface.
- [ ] **Z1** Dark-mode selector does nothing — nothing reads `data-theme-mode`. **S**
- [ ] **Z2** "Site Layout" selector has no effect — `layout.type` is edited but never read. **S**
- [ ] **Z3** Footer "Contacts" link has no content behind it. **S**
- [ ] **Z4** `SeoService.setItemMeta()` exists but is **never called** — product pages ship only site-wide meta. **S**
- [ ] **Z5** `og:locale` hardcoded to `ru_RU` regardless of active locale. **S**
- [ ] **Z6** No JSON-LD structured data, no sitemap generation. **M**
- [ ] **Z7** Hardcoded Russian payment-description fallback (`'Покупка на Маркетплейсе'`) in a multi-tenant product. **S**
- [ ] **Z8** Brand colours fail WCAG AA — `--border-color` at 1.241.42:1 against a 3:1 requirement; status colours fail 4.5:1 as text. **Needs theme-owner sign-off, not just a code fix.** **M**
- [ ] **Z9** Literal hex `#cdd6d5` in `stars.component.scss:10` with no token behind it. **S**
- [ ] **Z10** Two large lazy chunks unaddressed: `project-editor` (~1.0 MB), `catalog-container` (~330375 kB). Profile under real backend latency, not instant mock responses. **M**
- [ ] **Z11** `navigation.header` is editable in the builder with zero runtime consumer — needs a product decision, not a wiring fix. **DEC**
- [ ] **Z12** `catalog.navigationMode` renders a deliberate placeholder; the mega-menu / carousel / left-nav variants it implies do not exist. **DEC**
- [ ] **Z13** `sellerId` typed as bare `string` instead of the `UUID` alias used elsewhere. **S**
- [ ] **Z14** No shared breadcrumb component; the only breadcrumb logic is a local signal in the catalog container. **S**
- [ ] **Z15** Duplicate search models under two module paths. **S**
- [ ] **Z16** Consolidate the eight cross-linked Seller Management documents onto the now-resolved decision (unified orders, Sprint 0.1, 2026-08-17) — at least three independently restated the question before it was answered. Do this **before** Phase 5 starts. **M**
- [ ] **Z17** Angular 22 upgrade — researched, not started; needs a dependency fix and a Node bump. **Its own dedicated session, never bundled with feature work.** **M**
---
## Critical path
```
Sprint 0.1 (decisions)
└─> Sprint 0.2 (seams)
└─> Phase 1 (money truth) ──────────────┐
└─> Phase 2 (orders + notif) │
└─> Phase 3 (offer split) │
└─> Phase 4 (external ingestion)
└─> 🚦 LAUNCH GATE
Track A (analytics) ── starts at Phase 1, gates the launch ──┘
Track S (RBAC/audit) ── starts at Phase 2, gates the launch ──┘
Track Q (E2E) ── starts at Phase 1, evidences the gate ┘
```
Phases 510 all sit behind the launch gate and can be resequenced by business priority. Phases 14 cannot.
**Single hardest dependency:** Phase 1 Sprint 1.3 needs the payment chain unfrozen. If that answer is "no", the programme stops at Sprint 0.2 and the plan's P0s cannot be delivered — that outcome should go back to them in writing, not be worked around.

View File

@@ -0,0 +1,279 @@
# Product Plan v3.1 — What They Want vs. What We Have
**Source:** `Marketplaces-Platform-Product-Plan-v3.1.pdf` (27 pages, RU). Version block inside still reads `3.0 / 17 августа 2026` — the filename says v3.1. Section 14 is the v3.1 addition (appended after the document's own conclusion).
**Our side, as verified in this repo:** Angular frontend only (426 `.ts` files). Sources for "what we have": [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md), [GAPS-AND-IMPROVEMENTS.md](../GAPS-AND-IMPROVEMENTS.md), and direct source inspection.
---
## 1. What they are actually asking for
One sentence: **stop building storefronts, build a platform** — a single multi-tenant commerce core where launching a new marketplace is a configuration act, not an engineering project.
Their own acceptance bar (§"ГЛАВНЫЙ КРИТЕРИЙ" and §13):
> A real product walks the whole path: seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment → reconciliation.
Three things the document is really about, under the product language:
1. **They do not trust our numbers.** Traffic counters, payment timings, order totals and currency amounts are all called out as unexplainable. §10.2 says it outright: don't fix appearance, fix the data.
2. **They suspect demo behaviour in production.** "No fixed 5-second payment", "no synthetic traffic in production analytics", "no special branch for banks/inspectors" (§3.2, §3.1, §3.6, §10.2, and again in the launch checklist). This is an audit/compliance posture, not a feature request — a bank or NSPK is checking this platform.
3. **Commerce Core is no longer optional.** In v3.0 language, Catalog/Seller Portal/Cart/Checkout/Payments/Orders stopped being "a possible extension" and became mandatory platform modules. Gorbushka is demoted to "one tenant scenario" (§11) — it does not define the architecture.
**Launch blockers they define (§3, "LAUNCH BLOCKERS"):** all P0s — money/FX, payment timeline, notifications, external order ingestion, price traceability, guaranteed fulfillability of published offers.
---
## 2. What is new in v3.1 vs v3.0
Everything in **§14 "Customer Identity и коммуникация после покупки"** (pages 2627). Nothing else in the document is marked as changed.
| New in v3.1 | Detail | Our state |
|---|---|---|
| **VK ID as primary social login** | Backend completes OAuth 2.1/PKCE, links external identity to `Customer` | Zero. No `vk` reference anywhere in source; one `oauth` reference total. |
| **MAX messenger bot** | Bot-assisted account linking via one-time code; official MAX Bot API | Zero. |
| **Telegram demoted** | Kept, but as *one* identity provider among several | Today Telegram is the **only** login for both customers and admins. |
| **Notification Orchestrator** | Routes `order.paid` to the customer's chosen channel; backoffice notification always fires even if the messenger is down | Zero. |
| **Delivery Conversation State Machine** | `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`, bot collects delivery details, manager handoff | Zero. |
| **Channel choice in checkout** | "Where should we send confirmation?" — VK / MAX / Telegram / email-SMS fallback, recorded in `OrderContactSnapshot` | Zero. |
| **`ExternalIdentity` / `ContactChannel` / `BotConversationBinding` / `MessagingConsent`** | Four new entities | Zero. |
**Manager note:** §14 partially collides with our approved [email/phone OTP login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). v3.1 keeps email/phone but reduces them to *recovery/fallback* when a messenger is unavailable. Our in-flight work is still valid, but its priority drops below VK ID. Needs a call before that spec is implemented.
---
## 3. The differences — detailed
Legend: ✅ have · 🟡 partial / mock only · ❌ missing · ⚠️ conflicts with something we already decided.
### 3.1 Platform components (§1.1) — 8 named components, we have 2
| Plan component | Our state |
|---|---|
| Storefront Runtime | ✅ Bootstrap-driven, tenant-configured, no per-project fork. This is our strongest match to the plan. |
| Platform Backoffice | 🟡 14 admin modules exist, but only **Categories** has a real HTTP backend. 9 of 11 admin domains inject their mock gateway directly — no DI seam to swap at all. |
| Platform API | 🟡 Storefront catalog/search/cart-payment are live; everything admin-side is mock. |
| Seller Portal | ❌ A static placeholder page, feature flag `false` by default, zero backend bytes, zero `HttpClient` reference. |
| Workers / Event Processing | ❌ Nothing. No event bus, no retry, no dead-letter. |
| Integration Hub | ❌ Nothing. Zero `reconcil*`, zero `idempot*` in the whole codebase. |
| Domain Automation | ❌ Nothing. Zero `hostinger` references — the plan's §8.2 lists seven Hostinger DNS endpoints we have never touched. |
| Marketplace Registry / Launch Center | ❌ Nothing shipped. Closest thing is our unshipped [super-admin Phase 1 design](superpowers/specs/superuser.md), which covers cross-tenant *viewing* but not registry/feature-set/launch. |
### 3.2 Catalog model (§2.1) — the biggest structural gap
The plan's core catalog idea is a **two-layer split**: `Product` (content card) vs. `Offer/Listing` (the seller's commercial proposition, which owns price, stock, currency, status). Order lines then snapshot the offer.
| Plan entity | Our state |
|---|---|
| `Product` / `Variant` / `SKU` | 🟡 Exists as admin mock + a separate live storefront `Item` domain. Two unrelated `Category` types, both fed by the same response, both in use. |
| `Offer / Listing` | ❌ Does not exist. Price and stock hang off the product. Multi-seller pricing on one product card is not expressible. |
| `PriceSnapshot` | ❌ Does not exist. |
| `InventoryRecord` (available/reserved/sold) | ❌ Does not exist. No reservations, no TTL, no oversell queue. |
| `PriceHistory` | ❌ Does not exist. |
| Draft → moderation → published → paused/archived | 🟡 An admin Moderation module exists, on mock data. |
| Bulk import CSV/API with pre-apply error preview | ❌ Only bulk *edit* actions inside Admin Categories. No import pipeline. |
| "Storefront search/filters run on published data, not local mock arrays" | ⚠️ Directly aimed at us. `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` silently always resolve to the real API — but Search, wishlist/compare, cart contents and CMS are entirely `localStorage`. |
### 3.3 Money, FX and price traceability (§2.3, §3.3, §3.8, §7)
This is where the plan is most explicit, and where we most clearly do the forbidden thing.
| Plan requirement | Our state |
|---|---|
| `Money = amountMinor + currency`, **no float for money math** | ⚠️ We use plain `number` prices and float division/multiplication in `CurrencyRatesService.convert()`. |
| Rates come from a configurable **external source** with `source`, `rate`, `timestamp`, `TTL` | ⚠️ Rates are **hand-typed by an admin** into Admin Settings and stored in **browser `localStorage`** (`currencyRates.v1`), with hardcoded fallbacks (`USD: 0.011`, `AMD: 4.3`). They never update and drift from market. |
| `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` | ❌ Does not exist. |
| Stale-quote control blocks checkout | ❌ Does not exist. |
| Checkout writes an immutable price snapshot; old orders never recalculated | ❌ Does not exist. |
| `PriceBook` (base currency + allowed display/checkout currencies) | ❌ Does not exist. |
| Backoffice shows the total formula: lines × qty discounts + delivery + fees, plus the FX quote used | ❌ Does not exist. |
| Reconciliation of internal orders vs. provider transactions | ❌ Does not exist (`reconcil*` = 0 hits repo-wide). |
**Nuance worth telling them:** their §3.3 complaint is *"switching RUB/USD/AMD keeps the same number"*. Our storefront **does** convert the displayed number. Their real, unstated problem is the one our own [§12.7](../BACKEND-API-REFERENCE.md) already flagged: the **charged** amount is computed client-side in RUB and posted to `/cart` as `amount`, so bank settlement totals don't reconcile against order counts. We agree with the plan here — we raised it first.
### 3.4 Cart and Checkout (§2.5, §2.6) — ⚠️ head-on conflict with a frozen system
| Plan requirement | Our state |
|---|---|
| Cart is **server-side**, keyed on `offerId` | ⚠️ Cart is `localStorage` + Telegram CloudStorage. There is no backend cart at all. |
| "Client never sends a trusted price to the server" | ⚠️ `CartPaymentRequest` sends `amount`, `currency`, and a per-item `price` array from the browser. This is exactly the pattern the plan forbids. |
| Checkout is a **server session** producing a price snapshot + contact snapshot | ❌ Checkout is an inline popup in `pages/cart/cart.component.ts` (751 lines). `features/website/checkout/` is an empty directory. |
| Idempotent order creation keyed on the payment | ❌ `/orders` is called fire-and-forget after payment success. Zero `idempot*` in the codebase. |
| Backend re-validates offers/stock at checkout | ❌ No stock concept exists to validate. |
| Multi-seller cart grouped by seller and fulfillment rules | ❌ Undefined behaviour — already flagged in our own gaps doc. |
| No duplicate payment intents on double-click | 🟡 Popup state guards the UI; nothing server-side. |
**Blocker:** [BACKEND-API-REFERENCE.md §7](../BACKEND-API-REFERENCE.md) states *"Payments are frozen — this call chain is explicitly out of scope for changes."* The plan's P0-A and P0-C cannot be delivered without unfreezing it. **This needs an explicit decision from whoever froze it.**
### 3.5 Payments (§2.7, §3.2)
| Plan requirement | Our state |
|---|---|
| Explicit state machines: `PaymentIntent` / `Payment` / `Order` | ❌ None. Payment status is a client-side signal with values `creating/waiting/success/timeout/error`. |
| Webhook signature verification + idempotency | ❌ None. `webhook` appears only as a display field in the admin **monitoring mock**. |
| Store `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` | ❌ None. |
| "No artificial fixed delays" | ✅ **We already comply.** We poll real provider status (`/qr/dynamic/{partnerId}/{qrId}`, `/card/{partnerId}/{orderId}`) on an interval bounded by the QR TTL. There is no 5-second timer in this codebase. |
| Refunds as a first-class operation with reason/actor/order-line link | ❌ `requestRefund(id)` exists only as a mock gateway method. |
| Reconciliation queue | ❌ None. |
**Ask them:** §3.2 describes a fixed 5-second payment. We cannot reproduce it here. Either they observed a different build/environment, or they inferred it from the *admin* mock data. Worth pinning down before we spend P0 budget on a problem that may not be ours.
### 3.6 Orders and Fulfillment (§2.8, §3.6)
| Plan requirement | Our state |
|---|---|
| Canonical `Order` regardless of source (storefront / external marketplace / backoffice / API partner) | ❌ Admin Orders is a **static 24-row in-memory seed with no create path**, and no DI token to swap it. |
| `OrderLine` with SKU/title/price snapshots | ❌ |
| `Source mapping` (`externalMarketplace`, `externalOrderId`, `connectorId`) | ❌ |
| `Fulfillment` (manual / warehouse / pickup / digital) with evidence | ❌ One `fulfil*` hit in the entire codebase. |
| `Timeline` of all order events | ❌ Already logged as our own frontend-blocked TODO ("Real order audit trail"). |
| Admin actions: assign, resend notification, replay sync, cancel/refund by permission | ❌ |
| **No special branch for inspectors — any published, available product must be genuinely buyable and fulfillable** | ❌ We have no publish-time executability validation and no fulfillment flow, so we cannot currently *prove* compliance either way. |
### 3.7 Customer identity (§2.9, §3.4, §14)
| Plan requirement | Our state |
|---|---|
| `Customer` + multiple `ExternalIdentity` + verified `ContactMethod` | ❌ Telegram user is effectively the customer identity. |
| `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt` | ❌ |
| Order contact snapshot, immutable after order creation | ❌ |
| Email/phone OTP | 🟡 **Designed, not built** — spec approved 2026-08-15. |
| VK ID / MAX | ❌ New in v3.1, nothing exists. |
| Guest checkout toggled by tenant policy | ❌ |
### 3.8 Notifications (§2.10, §3.5)
| Plan requirement | Our state |
|---|---|
| Platform event bus emitting `order.created` / `order.paid` / `payment.failed` / `webhook.error` / `stock.low` / `oversell` / `refund.*` / `external_order.imported` | ❌ |
| Notification with `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link** | 🟡 `AdminOrderWatcherService` polls for new orders and toasts/badges the admin — the right shape, wrong data source. |
| Unread counter + filter by marketplace / event type in backoffice | 🟡 Partial (counter yes, marketplace filter no). |
| External channel delivery status logged; a Telegram/email failure must not lose the internal notification | ❌ |
**Status:** the notification feature is built and **functionally inert** — it polls the mock Orders gateway, which has no create path, so no new order can ever appear. It starts working the day Orders gets a real backend, with no further frontend change.
### 3.9 Analytics (§3.1, §6.3)
| Plan requirement | Our state |
|---|---|
| Server-side event logging: `session_started`, `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `payment_started/success/failed`, `order_created` | ❌ **No tracking pipeline exists at all.** Not a missing endpoint — missing infrastructure. Our own docs rate it the single largest remaining backend effort. |
| Operational metrics: notification latency, fulfillment time, connector lag, webhook lag | ❌ |
| Quality metrics: frontend/backend errors, checkout validation failures, FX stale blocks | ❌ |
| Real funnel in backoffice | ❌ Admin Analytics composes five mock gateways and has no data source. |
| Synthetic traffic technically separated from production analytics | ⚠️ Cannot comply — there is no production analytics to separate it from. |
| Product view counts | 🟡 A "Views" column was shipped in Admin Products; it always renders `0` because no tracking source exists. Storefront `Item.visits` is live-wired but displayed nowhere. |
### 3.10 Backoffice navigation (§4.1) — 12 required sections, 5 missing outright
Have (mock unless noted): Overview/Dashboard, Catalog (Categories real, Products mock), Orders, Payments partial (Transactions), Customers, Notifications partial, Content & Design (builder/CMS, `localStorage` only), Monitoring, Reports, Users, Settings.
Missing entirely:
- **Marketplaces** — registry, type, status, domains, currencies, feature set, responsible manager. Nothing.
- **Sellers** — organizations, applications, roles, listings, integration health. Placeholder page only.
- **Payments & Finance** — refunds, reconciliation, unmatched events, settlements. `settlement*` = 0 hits.
- **Integrations** — external connectors, payment providers, FX sources, messaging. Nothing.
- **Domains & Releases** — DNS/SSL, staging, production, health checks, rollback. Nothing.
- **Audit & Security** — role changes, sensitive actions, login/security events, exports. `audit` appears only as display fields on mock models.
### 3.11 Roles and RBAC (§4.4, §10.1) — ⚠️ our most serious security gap
The plan specifies three scopes and 17 named roles (5 platform, 7 marketplace, 5 seller).
Our state: **the admin role model is decorative.** `AdminRole` and permissions exist as types, but nothing gates any button, page or action anywhere in the app. Anyone who passes admin authentication has full access. `AdminRole` is additionally defined twice with unrelated shapes.
Also missing from §10.1: idempotency keys, rate-limit handling (429 has zero client-side handling), step-up authentication for financial actions, audit log, PII minimisation policy.
### 3.12 External marketplace integrations (§5) — 0% built
Nothing in this section exists in any form: connector contract, webhook-preferred/polling-fallback ingestion, raw event storage, normalizer, SKU mapping, unmatched queue, exponential retry, dead-letter, manual replay, reconciliation, connector observability, and the proposed SLA (99% of webhook events processed under 60s, zero duplicate orders).
**Blocking unknown:** the plan never names which external marketplaces. Ozon? Wildberries? Yandex Market? Avito? Each is a separate connector with its own auth and rate limits. We cannot size this without the list.
### 3.13 Domains, publishing and tenant launch (§8)
| Plan requirement | Our state |
|---|---|
| Marketplace lifecycle `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, with the blocking item shown per transition | ❌ |
| DNS automation via Hostinger API (7 endpoints listed), snapshot + rollback, never touching MX/SPF/DKIM/DMARC/CAA, approval gate in production, propagation + SSL + health checks | ❌ Zero references. |
| Publish model: `draft → validation → preview → publish`, immutable published revision, rollback creates a new revision | 🟡 The builder edits an in-memory config and persists drafts to `localStorage`. "Publish" only promotes a local signal. No revisions, no server-side publish endpoint (`apiEndpoints.builder` is an empty placeholder). |
| Commerce data explicitly **not** part of content revisions | ✅ Structurally true today — orders/payments simply aren't in the revision at all. |
### 3.14 API boundaries (§9.3) — ⚠️ a naming migration we have not planned
Plan namespaces: `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`.
Ours: unversioned, flat — `/cart`, `/orders`, `/items`, `/category`, `/searchitems`, plus a separate `qrApiUrl` host. Our own reference says **"No API versioning scheme has been decided"**.
Adopting the plan's namespaces is a coordinated frontend+backend rename, not a config change. It should be sequenced *before* the new commerce endpoints are built, not after.
Also in §9: the plan's error model assumes a structured envelope. Ours is a proposal only — no interceptor inspects error bodies today; every error reaction happens at raw HTTP-status level.
### 3.15 Gorbushka as a tenant (§11)
The plan lists mall-directory content entities: `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings` — each with `marketplaceId`, audit, and publish/preview.
We have: static pages inside the bootstrap document. None of the other nine entity types exist, and CMS content has no backend write path at all.
Positive read: the plan explicitly says Gorbushka must **not** dictate platform architecture, and that the existing frontend is UX reference only. That matches our ADR-0001 constraint ("frontend must not contain marketplace-specific code"). No conflict here — just unbuilt scope.
### 3.16 Definition of Done (§13) — where we stand today
Of the 13 launch-checklist items, we can currently claim **zero** as green. Additionally, our own QA position makes their DoD hard to evidence:
- ~32% statement coverage, ~19% branch coverage, 11 spec files repo-wide.
- **Zero E2E tests** — no Playwright/Cypress config anywhere. The plan's acceptance criteria are all end-to-end by construction.
- Several past "verified live" claims were code-inspection only, because `/edit` and `/backoffice` require Telegram admin login that automated environments cannot complete.
---
## 4. What we have that the plan does not account for
Not gaps — assets and risks they should know about before sequencing:
1. **Project editor / builder** (~1.0 MB lazy chunk) — a full visual site builder. The plan's §8.3 publish model would replace its persistence layer entirely.
2. **Ed25519 challenge/response admin auth** — fully wired client-side, backend returns 404 today. The plan never mentions it; it assumes conventional RBAC.
3. **Widget manifest / dynamic renderer** — the mechanism that makes one storefront runtime serve many tenants. This is the part of the plan we have *already* solved and should defend.
4. **Super-admin Phase 1 design** (`docs/superpowers/specs/superuser.md`) — cross-tenant read-only view. Overlaps §4.3 Marketplace Registry and §10 audit. Worth re-scoping against the plan rather than building as specified.
5. **Three in-flight items already answer v3.0 P0s:** admin purchase notifications (§3.5), admin product views column (§3.1), email/phone OTP login (§3.4). Two of the three are inert until a real backend exists.
---
## 5. Manager's read — the honest framing
**Split of ownership.** Roughly 80% of this document is backend and platform-service work: Platform API, Workers/Event Processing, Integration Hub, Domain Automation, payment state machines, reconciliation, analytics pipeline. This repository is a frontend. Of the plan's ~14 sections, only Storefront Runtime (§6.1) is substantially delivered, and it is delivered *well*.
**The real message is trust, not features.** Every P0 in §3 is a variant of "we cannot explain your numbers." Sequencing should follow that: traceability first (money model, price snapshot, payment timeline, audit), feature breadth second. That happens to also be the plan's own P0-A ordering.
**The largest single risk is not scope — it is the frozen payment chain.** Cart is client-owned, price is client-supplied, orders are fire-and-forget, and the whole chain is marked "do not modify." Three P0s sit behind that freeze. Nothing else in this list can be honestly estimated until that decision is reversed or explained.
**Second risk: RBAC.** The plan assumes 17 enforced roles across three scopes. We enforce none. Any real admin backend going live before this is fixed hands full platform access to every authenticated operator.
---
## 6. Decisions — answered 2026-08-17
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
1. **Backend ownership — still open.** First-pass question wasn't understood; needs re-asking in plain terms before Phase 1 implementation (not just contract-writing) starts.
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
5. **§14 vs. OTP — VK ID first, then everything else** ("do all after vk"). Phase 8 resequenced.
6. **Multi-seller orders — unified.** One `Order` per checkout, seller-scoped `Fulfillment` groups internally. Resolves the three-document disagreement.
7. **"Fixed 5-second payment" — resolved as a non-issue.** `PAYMENT_POLL_INTERVAL_MS` is already `5000` — that's poll cadence against real provider status, not an artificial delay. Confirmed compliant, no change needed.
8. **API namespace — new endpoints only, no forced migration.** `/api/v2/...` used for all new Phase 1+ contracts; legacy endpoints stay as-is pending a dedicated migration sprint.
9. **Document version — v3.1 is canonical.** The source PDF's internal "3.0" version block is stale.
---
## 7. Suggested first slice (if they want a proposal back)
Following their own dependency order, restricted to what is buildable and provable:
1. **Money model + FX quote + price snapshot** (P0-A) — needs the payment freeze lifted. Removes client-supplied `amount`, kills the float math, gives every total an explainable formula. This one item closes §3.3, §3.8 and half of §13.1.
2. **Order canonical model + timeline + notification wiring** (P0-B) — the notification feature already exists and switches on for free.
3. **RBAC enforcement** — not on their P0 list, but it is the gate on everything else in the backoffice going live safely.
4. **Analytics event pipeline** (P0/§3.1) — long lead time, so start it in parallel rather than last.
Explicitly *not* in a first slice: Seller Portal, external connectors, domain automation, VK/MAX bots. All of them depend on the commerce core being real first, which is what the plan itself says in §12.1.

View File

@@ -0,0 +1,229 @@
# Phase 1 Backend Contract — Money, FX, Price Snapshot, Payment State Machine
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 1 (Sprints 1.11.4) and [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md) §3.3/§3.5/§3.6.
**Status: unblocked (2026-08-17).** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) previously marked the cart/payment call chain frozen. Per the delivery plan's Sprint 0.1 decision, the freeze is lifted — this contract can move to implementation once backend ownership (also Sprint 0.1, still open) is confirmed.
This doc is the frontend's ask, in the same style as `BACKEND-API-REFERENCE.md`. It does not prescribe backend implementation (DB schema, service boundaries) — only the wire contract and the invariants the frontend needs to hold.
---
## 1. Why this exists
Current behaviour (`services/currency-rates.service.ts`, `pages/cart/cart.component.ts`):
- Currency conversion rates are typed by an admin into Admin Settings and persisted to browser `localStorage`. They never update and drift from market.
- The amount charged is computed **client-side** and sent as `CartPaymentRequest.amount` to `POST /cart`. The backend currently trusts this number.
- No record exists anywhere of which FX rate produced a given displayed price, or when it was captured.
Result: bank/NSPK settlement totals don't reconcile against order counts, because nothing on the backend can reconstruct *why* a given amount was charged. This document's contract exists to close that gap — it is the same complaint as Product Plan v3.1 §3.3/§3.8, and our own [§12.7](../../BACKEND-API-REFERENCE.md) raised it first.
---
## 2. Money representation
All money fields in every new endpoint below use minor units, never float.
```ts
interface Money {
amountMinor: number; // integer, no float. 4990 = 49.90 for a 2-decimal currency.
currency: string; // ISO 4217, e.g. "RUB" | "USD" | "EUR" | "AMD"
}
```
| Currency | Minor unit | Decimals |
|---|---|---|
| RUB | kopeck | 2 |
| USD | cent | 2 |
| EUR | cent | 2 |
| AMD | luma | 2 |
Rounding rule for any conversion: round half up to the currency's minor-unit precision, applied once, at the point of conversion — never re-rounded on redisplay.
---
## 3. FX Quote
### 3.1 Endpoint
```
GET /api/v2/pricing/fx-quote?base=RUB&quote=USD
```
```json
{
"quoteId": "fxq_8a3f1c2a",
"base": "RUB",
"quote": "USD",
"rate": 0.0108,
"source": "rapira",
"observedAt": "2026-08-20T09:14:00Z",
"expiresAt": "2026-08-20T09:19:00Z"
}
```
| Field | Notes |
|---|---|
| `quoteId` | Opaque, referenced by every `PriceSnapshot` that used this quote. |
| `rate` | `1 base = rate * quote`. Float is acceptable here — it's a market rate, not a money amount. |
| `source` | Adapter name. Frontend never hardcodes a provider; treat as an opaque label for display in the backoffice reconciliation panel. |
| `expiresAt` | TTL, provider-configurable. Frontend must not use an expired quote to display or charge. |
### 3.2 Stale-quote policy
- If the frontend holds a quote past `expiresAt`, it must re-fetch before checkout can proceed.
- If the rate source is unavailable, the backend decides: **block** (`503 SERVICE_UNAVAILABLE` with `error.code: "FX_SOURCE_UNAVAILABLE"`) or serve a configured fallback quote explicitly marked `"source": "fallback"`. Which policy applies is a tenant setting, not a frontend choice — see delivery-plan Sprint 0.1 decision on FX source.
- Outlier detection (e.g. a quote >X% off the previous one) is a backend concern; the frontend has no opinion on the threshold, only on obeying `expiresAt`.
---
## 4. PriceSnapshot
Created once, at checkout, immutable afterward. This is what makes a total explainable months later.
```ts
interface PriceSnapshot {
id: string;
offerId: string;
amount: Money; // price in the offer's base currency
displayAmount: Money; // price in the currency the customer checked out in
fxQuoteId: string | null; // null when displayAmount.currency === amount.currency
capturedAt: string; // ISO 8601
}
```
Rule: once a `PriceSnapshot` exists on an order line, it is never recalculated — not on rate update, not on currency-setting change, not on replay. An old order shows the price it was actually charged at.
---
## 5. Server-authoritative checkout amount
This is the contract change with the highest priority in Phase 1 — it removes the client-trusted `amount` field entirely.
### 5.1 Current (to be replaced)
```http
POST /cart
{ "amount": 4990, "currency": "RUB", "items": [{ "itemID": 101, "price": 4990, ... }], ... }
```
The backend trusts `amount` and each line's `price` as sent by the browser.
### 5.2 Target
```http
POST /api/v2/storefront/checkout
{
"offers": [{ "offerId": "off_9a1", "qty": 2 }],
"currency": "USD",
"deliveryOptionId": "del_standard"
}
```
```json
{
"checkoutSessionId": "chk_7f2e",
"lines": [
{
"offerId": "off_9a1",
"qty": 2,
"unitPrice": { "amountMinor": 5390, "currency": "USD" },
"lineTotal": { "amountMinor": 10780, "currency": "USD" },
"priceSnapshotId": "snap_3b1c"
}
],
"subtotal": { "amountMinor": 10780, "currency": "USD" },
"discount": { "amountMinor": 0, "currency": "USD" },
"delivery": { "amountMinor": 500, "currency": "USD" },
"total": { "amountMinor": 11280, "currency": "USD" },
"fxQuoteId": "fxq_8a3f1c2a",
"expiresAt": "2026-08-20T09:19:00Z"
}
```
**The frontend sends offer IDs and quantities. The backend computes every price, using the offer's live price and the current FX quote. No `amount` or `price` field is ever accepted from the client for anything that affects the charge.**
`POST /api/v2/storefront/payments/intents` then references `checkoutSessionId` only — the amount charged is read server-side from the checkout session, never re-sent by the client.
### 5.3 Total formula (must be reconstructable, per line)
```
order.total = sum(line.unitPrice * line.qty)
- discounts
+ delivery
+ taxes/fees (if applicable)
```
Backoffice must be able to render this formula, with the FX quote used, for any order — this is what Product Plan §7.2 asks for and what a bank reconciliation needs.
---
## 6. Payment state machine
### 6.1 States
```
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
Order: pending_payment -> paid -> processing -> fulfilled/completed
```
### 6.2 Required fields per transition
```ts
interface PaymentEvent {
id: string;
paymentIntentId: string;
fromState: string;
toState: string;
providerEventId: string; // idempotency key from the provider
providerTimestamp: string; // when the provider says it happened
receivedAt: string; // when our webhook received it
processedAt: string; // when our system finished processing it
}
```
No fixed delays anywhere in this chain. The frontend already complies with this (polls real provider status via `/qr/dynamic/{partnerId}/{qrId}` and `/card/{partnerId}/{orderId}` on an interval bounded by QR TTL) — this section documents the backend side of the same principle.
### 6.3 Webhook contract
```
POST /api/providers/v1/payments/{provider}/webhook
```
- Signature verification is mandatory; reject unsigned/invalid-signature payloads with `401`, do not silently accept.
- Idempotency key = `provider + providerEventId`. A repeated delivery of the same event must be a no-op — same `PaymentEvent` row, no second order, no second notification.
- On success, emit `payment.confirmed` / `payment.failed` onto the platform event bus (Phase 2) so Order creation is driven by the event, not by the webhook handler doing double duty.
### 6.4 Idempotent order creation
```
POST /api/admin/v2/orders (internal, from the payment-confirmation handler)
Idempotency-Key: <checkoutSessionId>
```
A retried call with the same `checkoutSessionId` must return the existing order, not create a second one. This is the mechanism that makes "double-click doesn't create two orders" true regardless of frontend debouncing.
---
## 7. What the frontend will stop doing once this ships
- Delete `CurrencyRatesService`'s `localStorage`-persisted admin-typed rates and hardcoded `DEFAULT_RATES` fallback (`USD: 0.011`, `AMD: 4.3`).
- Delete the Admin Settings currency-rate editor UI.
- Stop sending `amount` / `price` in any checkout-related request.
- Replace client-side float conversion (`CurrencyRatesService.convert()`) with server-supplied `Money` values everywhere a price is displayed.
## 8. What the frontend will start doing
- Fetch `GET /api/v2/pricing/fx-quote` on currency switch; block checkout if the held quote has expired.
- Render the backoffice "total formula" panel (lines × qty discounts + delivery + fees, FX quote used) once §5.2 and the admin Orders API exist (Phase 2).
- Surface `FX_SOURCE_UNAVAILABLE` and `error.code`-driven stale-quote UI per the error envelope in `BACKEND-API-REFERENCE.md §5`.
---
## 9. Resolved / open questions (Sprint 0.1, 2026-08-17)
1. **Payment chain freeze — lifted.** §5 can proceed.
2. **FX rate source/provider — ours, in-house, as the default (not just a fallback).** No external provider committed. Backend computes and serves the quote itself; the `source` field in §3.1 can legitimately read `"internal"` as the normal case. Revisit if an external provider is chosen later — the contract shape doesn't need to change, only the value of `source`.
3. **Backend-converted prices vs. frontend-requested display currency — still open, needs confirmation before implementation.** This doc's §5.2 models the frontend sending a target `currency` and the backend returning the converted total. Confirm this is the intended flow before backend implementation starts.
4. **Backend ownership — still open.** This contract is ready regardless of who builds against it, but implementation can't be scheduled until this is answered.

View File

@@ -0,0 +1,118 @@
# Phase 10 Backend Contract — Tenant Content Modules (Gorbushka-class tenants)
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 10 (Sprints 10.110.2). Covers plan §11.
**Status: ready to build, lowest priority.** Only after Commerce Core (Phases 17) is real — the plan is explicit that this tenant type does not define the platform architecture; it is one configuration of the shared runtime, not a separate build.
---
## 1. Entities
```ts
interface Shop {
id: string;
marketplaceId: string;
shopCategoryId: string;
name: string;
floorId?: string;
status: 'draft' | 'published';
}
interface ShopCategory {
id: string;
marketplaceId: string;
title: string;
}
interface Service {
id: string;
marketplaceId: string;
title: string;
description: string;
status: 'draft' | 'published';
}
interface Floor {
id: string;
marketplaceId: string;
order: number;
label: string;
}
interface SchemePin {
id: string;
marketplaceId: string;
floorId: string;
shopId?: string;
x: number;
y: number;
}
interface RentListing {
id: string;
marketplaceId: string;
title: string;
areaSqm: number;
floorId?: string;
status: 'available' | 'leased';
}
interface Lead {
id: string;
marketplaceId: string;
rentListingId?: string;
contactName: string;
contactPhone: string;
message?: string;
createdAt: string;
}
interface NewsPromo {
id: string;
marketplaceId: string;
title: string;
body: string;
publishedAt?: string;
}
interface MallSettings {
marketplaceId: string;
openingHours: Record<string, string>;
contactInfo: Record<string, string>;
}
```
Every entity above carries `marketplaceId`, an audit trail, and the same draft/preview/publish flow as [Phase 9's revision model](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §5 — not a separate content pipeline.
## 2. Endpoints
```
GET/POST/PATCH/DELETE /api/admin/v2/content/shops
GET/POST/PATCH/DELETE /api/admin/v2/content/shop-categories
GET/POST/PATCH/DELETE /api/admin/v2/content/services
GET/POST/PATCH/DELETE /api/admin/v2/content/floors
GET/POST/PATCH/DELETE /api/admin/v2/content/scheme-pins
GET/POST/PATCH/DELETE /api/admin/v2/content/rent-listings
POST /api/admin/v2/content/rent-listings/{id}/leads
GET/POST/PATCH/DELETE /api/admin/v2/content/news
PATCH /api/admin/v2/content/mall-settings
```
## 3. Tenant feature configuration (Gorbushka's v1 default, per plan §11.1)
```json
{
"cms": true, "shops": true, "services": true, "mallScheme": true,
"rentListings": true, "news": true, "seoMedia": true,
"catalog": false, "sellerPortal": false,
"cart": false, "checkout": false, "payments": false, "orders": false
}
```
Commerce modules are **platform-ready but off** — the point of Phase 10 is proving this tenant can flip `catalog`/`cart`/`checkout`/etc. to `true` later via [Phase 9's `MarketplaceFeatureSet`](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) with zero backend or storefront code changes, since the commerce core is already generic by the time Phase 10 starts.
## 4. What the frontend will start doing once this ships
- Mall scheme / floor / pin editor UI.
- Rent listing + lead capture forms.
- Confirm the existing Gorbushka frontend/archive is used as UX reference only — production data and auth route through the shared platform per ADR-0001.

View File

@@ -0,0 +1,152 @@
# Phase 2 Backend Contract — Canonical Orders, Event Bus, Notification Center
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 2 (Sprints 2.12.2). Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (Money/PriceSnapshot/PaymentIntent) being implemented first — an Order line references a `priceSnapshotId` from that contract.
**Status: ready to build.** No open decisions block this phase.
---
## 1. Why this exists
Today `AdminOrdersLocalGateway` is a static 24-row in-memory seed with no create path — a real order can never appear. `AdminOrderWatcherService` already polls for new orders to toast/badge the admin, but is functionally inert against the mock. This contract makes both real.
## 2. Multi-seller model — Sprint 0.1 decision: unified
**One `Order` per checkout, regardless of how many sellers are represented.** Lines are grouped into per-seller `Fulfillment` entries internally. There is no parent/child order splitting, no separate order-per-seller. A seller only ever sees their own `Fulfillment` group within a shared order (see [Phase 5 contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) for the seller-scoped view).
## 3. Entities
```ts
interface Order {
id: string;
marketplaceId: string;
source: 'storefront' | 'external' | 'backoffice' | 'api_partner';
externalOrderRef?: string; // set when source === 'external', see Phase 4
customerId?: string;
currency: string;
subtotal: Money;
discount: Money;
delivery: Money;
total: Money;
paymentStatus: 'pending_payment' | 'paid' | 'failed' | 'refunded' | 'partially_refunded';
orderStatus: 'pending_payment' | 'paid' | 'processing' | 'fulfilled' | 'completed' | 'cancelled';
createdAt: string;
paidAt?: string;
}
interface OrderLine {
id: string;
orderId: string;
offerId: string; // see Phase 3 contract
sellerId: string;
skuSnapshot: string;
titleSnapshot: string;
qty: number;
unitPrice: Money;
lineTotal: Money;
priceSnapshotId: string; // references Phase 1's PriceSnapshot
}
interface Fulfillment {
id: string;
orderId: string;
sellerId: string; // the seller-scoping unit for the unified-order model
type: 'manual' | 'warehouse' | 'pickup' | 'digital';
status: 'pending' | 'assigned' | 'in_progress' | 'issued' | 'shipped' | 'cancelled';
assignedTo?: string;
issuedAt?: string;
shippedAt?: string;
evidence?: { type: string; url: string }[]; // e.g. shipment proof, digital delivery receipt
}
interface OrderEvent {
id: string;
orderId: string;
type: 'created' | 'paid' | 'seller_notified' | 'accepted' | 'fulfilled' | 'cancelled' | 'refunded';
actor?: string; // user/system id, null for automated system events
occurredAt: string;
metadata?: Record<string, unknown>;
}
interface OrderContactSnapshot {
orderId: string;
name: string;
email?: string;
phone?: string;
preferredChannel?: 'telegram' | 'vk' | 'max' | 'email' | 'sms';
capturedAt: string; // immutable after order creation, independent of later Customer profile edits
}
```
## 4. Endpoints
```
GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=
GET /api/admin/v2/orders/{id}
PATCH /api/admin/v2/orders/{id}/status { status }
POST /api/admin/v2/orders/{id}/refund-request { reason }
POST /api/admin/v2/orders/{id}/notes { note, internal: boolean }
POST /api/admin/v2/orders/{id}/archive
POST /api/admin/v2/orders/{id}/restore
DELETE /api/admin/v2/orders/{id}
GET /api/seller/v1/orders?fulfillmentStatus=&page=&pageSize=
-> returns Order + only the Fulfillment groups belonging to the authenticated seller,
OrderLines filtered to that seller's lines. Never the full order's other-seller lines.
```
Replaces `AdminOrdersLocalGateway` behind the `ADMIN_ORDERS_GATEWAY` token already wired this session (see [BACKEND-API-REFERENCE.md §8](../../BACKEND-API-REFERENCE.md)) — no facade change needed, only binding a real `AdminOrdersApiGateway`.
## 5. Event bus
```ts
type PlatformEvent =
| { type: 'order.created'; orderId: string; marketplaceId: string }
| { type: 'order.paid'; orderId: string; marketplaceId: string }
| { type: 'payment.failed'; orderId: string; reason: string }
| { type: 'webhook.error'; source: string; traceId: string }
| { type: 'stock.low'; offerId: string; available: number }
| { type: 'oversell'; offerId: string; requested: number; available: number }
| { type: 'refund.requested'; orderId: string; refundId: string }
| { type: 'refund.completed'; orderId: string; refundId: string }
| { type: 'external_order.imported'; orderId: string; connectorId: string };
```
Backend owns the bus implementation (queue, pub/sub, whatever fits existing infra). Frontend's only contract: the Notification entity below, and the requirement that `order.paid` always produces a backoffice notification **even if every external channel is down** (see [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) §5 for the messenger-side orchestration).
## 6. Notification Center
```ts
interface Notification {
id: string;
marketplaceId: string;
entityType: 'order' | 'payment' | 'offer' | 'connector' | 'refund';
entityId: string;
severity: 'info' | 'warning' | 'critical';
eventType: PlatformEvent['type'];
read: boolean;
deepLink: string; // e.g. /admin/orders/{id}
createdAt: string;
}
interface DeliveryAttempt {
notificationId: string;
channel: 'telegram' | 'email' | 'sms' | 'vk' | 'max';
status: 'sent' | 'failed';
error?: string;
attemptedAt: string;
}
```
```
GET /api/admin/v2/notifications?marketplaceId=&unreadOnly=&eventType=
PATCH /api/admin/v2/notifications/{id}/read
```
Invariant: a `DeliveryAttempt` failure on an external channel **never** prevents the `Notification` row itself from being created and visible in the backoffice unread queue.
## 7. What the frontend will start doing once this ships
- Repoint `AdminOrderWatcherService` from polling `AdminOrdersLocalGateway` to the event stream / `GET /api/admin/v2/notifications?unreadOnly=true`.
- Build the backoffice **Notifications** section (unread queue, severity, marketplace/event-type filter) — currently missing from admin nav entirely.
- Wire admin order actions (assign, resend notification, replay sync, cancel/refund, comment, export) to the endpoints in §4.

View File

@@ -0,0 +1,144 @@
# Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 3 (Sprints 3.13.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.
**Status: ready to build.** No open decisions block this phase.
---
## 1. Why this exists
Today price, stock and currency hang directly off a single admin `Product` mock domain, unrelated to the live storefront `Item` domain. A product cannot have two sellers, two prices, or two stock levels. `Offer/Listing` does not exist in any form.
## 2. The two-layer split
`Product` describes the item itself (content). `Offer` describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.
```ts
interface Product {
id: string;
marketplaceId: string;
categoryId: string;
brand?: string;
title: string;
description: string;
attributes: Record<string, unknown>;
media: string[];
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
}
interface Variant {
id: string;
productId: string;
sku: string;
barcode?: string;
optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
dimensions?: { weight?: number; length?: number; width?: number; height?: number };
}
interface Category {
id: string;
marketplaceId: string;
parentId: string | null;
slug: string;
attributesSchema: Record<string, unknown>;
order: number;
seo: { title?: string; description?: string };
}
interface Offer {
id: string;
marketplaceId: string;
sellerId: string;
variantId: string;
sellerSku: string;
price: Money; // Money type from Phase 1 contract
stockPolicy: 'track' | 'no_track' | 'preorder';
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
publishedAt?: string;
executabilityChecked: boolean; // see §5
}
interface PriceHistory {
offerId: string;
price: Money;
changedBy: string; // user id or 'sync:{connectorId}'
changedAt: string;
}
```
## 3. Inventory
```ts
interface InventoryRecord {
offerId: string;
available: number;
reserved: number;
sold: number;
warehouse?: string;
source: 'manual' | 'feed_sync' | 'connector';
}
interface StockReservation {
id: string;
offerId: string;
qty: number;
reason: 'checkout' | 'pre_payment';
expiresAt: string; // TTL
released: boolean;
}
```
Invariants:
- `available`, `reserved`, `sold` are counted separately, never derived from one another implicitly.
- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing `reserved` back to `available`.
- Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement.
- Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected.
## 4. Lifecycle
```
draft -> moderation -> published -> paused/archived
```
Applies independently to both `Product` and `Offer`. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind `ADMIN_MODERATION_GATEWAY` (token already added this session).
## 5. Publish-time executability
**An offer that cannot actually be fulfilled must not be publishable.** Before allowing `status: 'published'`, the backend validates:
- The offer has a valid `Fulfillment` type it can realistically satisfy (see [Phase 2 contract](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) `Fulfillment.type`).
- Stock policy is `track` with `available > 0`, or `no_track`/`preorder` explicitly.
- Required attributes for the offer's category (`Category.attributesSchema`) are present.
This is the mechanism behind the plan's §3.6/§10.2 requirement: **no branch anywhere may distinguish a normal buyer from an inspector.** The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.
## 6. Bulk import
```
POST /api/admin/v2/products/bulk-import
Content-Type: multipart/form-data (CSV) or application/json (array)
```
Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review.
## 7. Endpoints
```
GET /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
GET /api/admin/v2/products/{id}
POST /api/admin/v2/products
PATCH /api/admin/v2/products/{id}
GET /api/admin/v2/offers?productId=&sellerId=&status=
POST /api/admin/v2/offers
PATCH /api/admin/v2/offers/{id}
POST /api/admin/v2/offers/{id}/publish -> runs §5 executability check, 422 with details[] on failure
GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId= -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1
```
Replaces `AdminProductsLocalGateway` behind `ADMIN_PRODUCTS_GATEWAY` (token already wired this session).
## 8. What the frontend will start doing once this ships
- Unify the admin mock product domain with the live storefront `Item` domain — currently two unrelated shapes.
- Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
- Wire the Moderation module to real lifecycle transitions instead of mock data.

View File

@@ -0,0 +1,123 @@
# Phase 4 Backend Contract — External Order Connector Framework
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 4 (Sprint 4.1, generic framework; Sprint 4.2 retired as "per named marketplace"). Depends on [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) (`Offer`/`sellerSku` must exist to map onto) and [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) (`Order` canonical model).
**Status: ready to build, generic by design.** Sprint 0.1 decision (2026-08-17): no fixed marketplace list — "our new ones, partners, new, etc." This contract specifies a config-driven framework, not a per-provider integration. Zero of this exists in the codebase today (`reconcil*`, `idempot*`, `hostinger` all return 0 hits).
---
## 1. Design principle
**A new partner connector is an onboarding action against this framework, not a code change.** Auth type, field mapping, and rate limits are configuration; the pipeline (ingest → normalize → map → idempotency-check → create/update order → notify) is fixed and shared across every connector.
## 2. Entities
```ts
interface Connector {
id: string;
marketplaceId: string;
provider: string; // free-text label, e.g. "ozon", "wildberries" - not an enum, new values need no code change
authType: 'webhook_signed' | 'api_key' | 'oauth2';
credentialRef: string; // pointer into secret storage, never the secret itself
pollingIntervalSeconds?: number; // set only when the provider has no webhook
cursorState?: string; // opaque, connector-specific pagination/since cursor
status: 'active' | 'paused' | 'error';
}
interface RawExternalEvent {
id: string;
connectorId: string;
payload: unknown; // stored verbatim, before any parsing - the traceability anchor
receivedAt: string;
processedAt?: string;
}
interface ExternalOrderMapping {
connectorId: string;
externalSellerId: string;
externalProductId: string;
externalSku: string;
internalSellerId: string;
internalOfferId: string; // references Phase 3's Offer
}
interface DeadLetter {
id: string;
connectorId: string;
rawEventId: string;
reason: string;
retryCount: number;
lastAttemptAt: string;
resolvedAt?: string;
}
```
## 3. Pipeline (fixed, shared across every connector)
```
1. Connector receives webhook, or polling finds a new event via cursorState.
2. Signature/auth verified. Idempotency key = connectorId + externalOrderId/eventId.
3. Payload persisted as RawExternalEvent BEFORE any parsing.
4. Normalizer maps payload -> canonical ExternalOrderEvent shape (fixed schema, see §4).
5. SKU mapping resolves externalSku -> internal Offer via ExternalOrderMapping.
No mapping found -> event goes to the Unmatched queue (§5), does NOT fail silently.
6. Order created/updated via the Phase 2 Order API, source: 'external', externalOrderRef set.
7. external_order.imported and order.created events emitted (Phase 2 event bus).
8. Fulfillment/status changes pushed back to the external marketplace if its API supports it.
```
## 4. Canonical external order event (what the normalizer produces)
```ts
interface ExternalOrderEvent {
connectorId: string;
externalOrderId: string;
externalCreatedAt: string;
customer: { name?: string; contact?: string };
lines: Array<{ externalSku: string; qty: number; unitPriceMinor: number; currency: string }>;
totalMinor: number;
currency: string;
rawEventId: string; // traceability back to §2
}
```
Every provider's adapter is responsible only for producing this shape from its own payload — everything downstream (§3 steps 58) is provider-agnostic.
## 5. Unmatched queue + retry
```
GET /api/admin/v2/integrations/{connectorId}/unmatched
POST /api/admin/v2/integrations/{connectorId}/unmatched/{eventId}/resolve { internalOfferId }
POST /api/admin/v2/integrations/{connectorId}/dead-letter/{id}/replay
```
Retry policy: exponential backoff, capped attempts, then `DeadLetter` with manual replay from backoffice. No connector is allowed to silently drop an event.
## 6. Connector-agnostic SLA (applies to every provider, per plan §5.2)
- Webhook source: 99% of valid events processed in under 60 seconds.
- Polling source: delay no worse than `pollingIntervalSeconds + 60`.
- **Zero** duplicate orders on repeated event delivery (guaranteed by the idempotency key in §3 step 2).
- Every connector error carries a trace id, visible in backoffice.
## 7. Endpoints
```
POST /api/providers/v1/{connector}/webhook -- generic entrypoint, connector resolved by path + auth
GET /api/admin/v2/integrations -- list all connectors + health (last success, lag, errors, backlog)
POST /api/admin/v2/integrations -- onboard a new connector: { provider, authType, credentialRef, marketplaceId }
PATCH /api/admin/v2/integrations/{id} -- pause/resume, update mapping config
```
## 8. Onboarding a new partner (replaces the old "one sprint per named marketplace")
1. Register credentials in secret storage, scoped to marketplace/seller.
2. `POST /api/admin/v2/integrations` with the provider's auth type and mapping config.
3. Write the provider-specific adapter (payload → §4 canonical shape) — the only genuinely bespoke piece per partner.
4. Verify in sandbox against the fixed pipeline (§3) — nothing else changes.
## 9. What the frontend will start doing once this ships
- Build the backoffice **Integrations** section (missing from admin nav today): connector list, health (last success/lag/errors/backlog/unmatched), FX sources, messaging providers.
- Trace-id surfacing on connector errors.
- Unmatched-queue resolution UI.

View File

@@ -0,0 +1,88 @@
# Phase 5 Backend Contract — Seller Portal
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 5 (Sprints 5.15.3). Depends on [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) (Offer) and [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) (unified Order + Fulfillment).
**Status: ready to build behind the launch gate.** Frontend note: Seller Management is currently a static placeholder, feature-flagged off by default, with **zero backend bytes and zero `HttpClient` reference** — this contract is a from-scratch build, not a gateway swap.
---
## 1. Multi-seller model reminder
Per the Phase 2 unified-orders decision: a seller never owns a separate `Order`. They see the `Fulfillment` group(s) that belong to them within shared orders, and the `OrderLine`s scoped to their `sellerId`. All endpoints below are pre-filtered server-side to the authenticated seller — never trust a frontend-supplied `sellerId` filter.
## 2. Entities
```ts
interface SellerOrganization {
id: string;
marketplaceId: string;
legalName: string;
status: 'pending' | 'approved' | 'suspended' | 'rejected';
bankDetailsRef: string; // pointer into secret storage, never raw account numbers over the wire
createdAt: string;
}
interface SellerUser {
id: string;
sellerOrganizationId: string;
role: 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER' | 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
email: string;
status: 'active' | 'invited' | 'suspended';
}
interface SellerMarketplaceMembership {
sellerOrganizationId: string;
marketplaceId: string;
status: 'pending' | 'approved' | 'suspended';
}
interface SellerIntegration {
sellerOrganizationId: string;
apiCredentialRef: string;
webhookUrl?: string;
lastSyncAt?: string;
lastSyncError?: string;
}
```
## 3. Endpoints (all scoped server-side to the authenticated seller's org)
```
POST /api/seller/v1/onboarding { legalName, contacts, marketplaceId }
GET /api/seller/v1/profile
GET /api/seller/v1/offers?status=&page=
POST /api/seller/v1/offers
PATCH /api/seller/v1/offers/{id}
POST /api/seller/v1/offers/bulk-price-update -- mass price/stock edit, see Phase 3 §6 for the shared bulk-import pattern
GET /api/seller/v1/orders?fulfillmentStatus=
PATCH /api/seller/v1/orders/{orderId}/fulfillment/{fulfillmentId} { status, evidence }
GET /api/seller/v1/finance/accruals
GET /api/seller/v1/finance/settlements
POST /api/seller/v1/finance/bank-details -- step-up auth + audit event required, see §5
GET /api/seller/v1/team
POST /api/seller/v1/team/invite { email, role }
GET /api/seller/v1/integrations
```
## 4. Roles (fixed set, enforced backend-side)
```
SELLER_OWNER - full access within the org
SELLER_CATALOG_MANAGER - offers/catalog only
SELLER_ORDER_MANAGER - orders/fulfillment only
SELLER_FINANCE_VIEWER - read-only finance
SELLER_VIEWER - read-only everything
```
No UI-only gating. Every endpoint above checks `SellerUser.role` server-side regardless of what the frontend renders — this is the same principle as [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md), scoped to the seller domain specifically.
## 5. Sensitive-action rules
- Bank/payment detail changes (`POST .../finance/bank-details`) require step-up authentication, produce an audit event, and — if maker/checker mode is enabled for the tenant — require a second approver before taking effect.
- A seller can never query, by any endpoint or parameter manipulation, another seller's products, orders, customers, finance data, or API keys. This must be enforced at the query layer (implicit `WHERE sellerOrganizationId = :authenticatedSeller`), not left to the frontend to "not ask for it."
## 6. What the frontend will start doing once this ships
- Replace the static Seller Management placeholder with real screens: Onboarding, Catalog, Prices & Stock, Orders, Finance, Team, Integrations (per plan §2.2).
- Resolve the two competing seller type shapes flagged in `GAPS-AND-IMPROVEMENTS.md` (`SellerConfig` in bootstrap models vs. `Seller`/`SellerBranding` in the domain layer) against this contract's `SellerOrganization`/`SellerUser` shapes.
- First-ever exercise of the `sellerManagement.enabled` flag at `true` — write a fixture test, since it has never been tested at its real-world-eventual value.

View File

@@ -0,0 +1,90 @@
# Phase 6 Backend Contract — Server Cart + Checkout Session
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 6 (Sprints 6.16.2). Extends [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5 (server-authoritative checkout amount) into a full server-owned cart.
**Status: ready to build** — payment chain unfrozen per Sprint 0.1.
---
## 1. Why this exists
Cart today is `localStorage` + Telegram CloudStorage — no backend cart exists at all. `features/website/checkout/` is an empty directory; checkout lives entirely inside a 751-line cart popup component. Phase 1 §5 already specifies the server-authoritative *amount* at checkout time; this phase makes the *cart itself* server-owned, from add-to-cart onward.
## 2. Entities
```ts
interface Cart {
id: string;
marketplaceId: string;
customerId?: string; // set for authenticated customers
sessionToken?: string; // set for guest carts
createdAt: string;
expiresAt: string; // TTL for inactive carts
}
interface CartLine {
id: string;
cartId: string;
offerId: string; // never a client-supplied price - see Phase 1 §5
qty: number;
addedAt: string;
}
interface CheckoutSession {
id: string;
cartId: string;
customerContact: { email?: string; phone?: string; verified: boolean };
deliveryOptionId: string;
status: 'open' | 'confirmed' | 'expired';
createdAt: string;
expiresAt: string;
}
interface DeliveryOption {
id: string;
marketplaceId: string;
label: string;
price: Money;
type: 'pickup' | 'courier' | 'digital';
}
```
## 3. Cart endpoints
```
POST /api/v2/storefront/cart/lines { offerId, qty }
PATCH /api/v2/storefront/cart/lines/{lineId} { qty }
DELETE /api/v2/storefront/cart/lines/{lineId}
GET /api/v2/storefront/cart
```
Invariants:
- Idempotent add/update/remove.
- Quantity validated against `Offer`/`InventoryRecord` (Phase 3) on every mutation, not just at checkout.
- Guest cart identified by `sessionToken` (cookie or header); authenticated cart bound to `customerId`. Adding to a guest cart, then logging in, must merge into the customer's cart — not silently drop items.
- Inactive carts and their `StockReservation`s (Phase 3 §3) clear on `expiresAt`.
## 4. Price-refresh rule
If an offer's price changed since it was added to the cart, `GET /api/v2/storefront/cart` returns both the line's captured price and the current price, with a `priceChanged: boolean` flag. The frontend must show this and require explicit confirmation before checkout proceeds if the total moved — this is a UX requirement on the frontend, but the backend must expose the comparison, not silently use whichever price it prefers.
## 5. Checkout session
Builds directly on [Phase 1 §5.2](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md#5-server-authoritative-checkout-amount):
```
POST /api/v2/storefront/checkout { cartId, currency, deliveryOptionId }
```
reads the server-owned `Cart`/`CartLine`s directly (no client-supplied offer list needed anymore, unlike the Phase 1 doc's example which pre-dates the server cart). Response shape unchanged from Phase 1 §5.2.
Additional checkout-time validation beyond Phase 1:
- Contact requirement enforced per tenant policy: email and/or phone must be present and (if the tenant requires it) verified before `CheckoutSession.status` can move to `confirmed`.
- Guest checkout allowed/disallowed per tenant policy (`MarketplaceFeatureSet`, see [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)).
## 6. What the frontend will start doing once this ships
- Build the `features/website/checkout/` module for real — currently an empty directory.
- Retire `localStorage`/Telegram-CloudStorage cart persistence.
- Show the price-refresh confirmation UI described in §4.
- Delete the client-side offer/qty tracking currently duplicated inside the cart popup component.

View File

@@ -0,0 +1,92 @@
# Phase 7 Backend Contract — Refunds + Reconciliation
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 7 (Sprints 7.17.3). Extends [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6 (payment state machine).
**Status: ready to build.** `requestRefund(id)` exists today only as a mock gateway method; `reconcil*` and `settlement*` return zero hits anywhere in the codebase.
---
## 1. Refunds
```ts
interface Refund {
id: string;
orderId: string;
orderLineIds: string[]; // which lines this refund covers - partial refunds must specify
amount: Money;
reason: string;
actor: string; // user id who initiated it, never anonymous
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
requestedAt: string;
completedAt?: string;
}
```
```
POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }
GET /api/admin/v2/orders/{orderId}/refunds
```
A `Refund` updates `Payment.status` to `refunded` or `partially_refunded` (Phase 1 §6.1) and emits `refund.requested`/`refund.completed` on the Phase 2 event bus.
## 2. Reconciliation
```ts
interface ReconciliationRecord {
id: string;
orderId: string;
providerPaymentId?: string;
internalAmount: Money;
providerAmount?: Money;
matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback';
result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch';
resolvedBy?: string;
resolvedAt?: string;
resolutionNote?: string;
}
```
Process (per plan §7.3):
```
1. Collect internal paid orders for a period.
2. Fetch provider transactions/events for the same period.
3. Match by providerPaymentId, falling back to merchant reference, falling back to amount+currency.
4. Classify: matched / unmatched / duplicate / amount_mismatch / status_mismatch.
5. Surface the non-matched set in backoffice with controlled, audited resolution.
```
```
GET /api/admin/v2/reconciliation/queue?marketplaceId=&result=
POST /api/admin/v2/reconciliation/{id}/resolve { note }
```
## 3. Settlements
```ts
interface Settlement {
id: string;
sellerId: string;
periodStart: string;
periodEnd: string;
grossAmount: Money;
commission: Money;
refunds: Money;
netPayout: Money;
status: 'pending' | 'paid';
}
```
```
GET /api/seller/v1/finance/settlements
GET /api/admin/v2/finance/settlements?sellerId=&period=
```
## 4. Provider breadth (open business question)
Current flow supports QR and card only, via one custom provider integration. Adding wallets/BNPL is an explicit open business decision (not answered in Sprint 0.1) — this contract's `PaymentIntent`/`Payment` shapes from Phase 1 §6 are provider-agnostic already, so a new provider is a new adapter behind the same state machine, not a schema change. No action needed here until that business decision is made.
## 5. What the frontend will start doing once this ships
- Wire the mock `requestRefund(id)` to a real endpoint.
- Build the backoffice **Payments & Finance** section (missing from admin nav today): payments, refunds, reconciliation queue, unmatched events, settlements.
- Reconciliation-queue resolution UI with full audit trail.

View File

@@ -0,0 +1,151 @@
# Phase 8 Backend Contract — Customer Identity, VK ID, MAX/Telegram Messaging
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 8 (Sprints 8.18.5). Covers plan §2.9, §3.4, and all of §14 (the v3.1-only addition).
**Status: ready to build. Sprint order fixed by Sprint 0.1 decision: VK ID first, then everything else** ("do all after vk"). Sequence below follows that: identity core → VK ID → email/phone OTP → MAX/Telegram → Notification Orchestrator.
---
## 1. Entities
```ts
interface Customer {
id: string;
marketplaceId: string; // or global identity strategy, tenant-configurable
name?: string;
email?: string;
phone?: string;
status: 'active' | 'suspended';
createdAt: string;
}
interface ExternalIdentity {
customerId: string;
provider: 'vk_id' | 'telegram' | 'max';
providerUserId: string;
verifiedAt: string;
metadata: Record<string, unknown>;
lastUsedAt: string;
}
interface ContactMethod {
customerId: string;
type: 'email' | 'phone';
value: string;
verifiedAt?: string;
}
interface ContactChannel {
customerId: string;
provider: 'telegram' | 'vk' | 'max';
chatId: string;
verified: boolean;
notificationsEnabled: boolean;
deliveryEnabled: boolean;
}
interface MessagingConsent {
customerId: string;
channel: string;
purpose: 'marketing' | 'order_service_messages';
grantedAt?: string;
revokedAt?: string;
}
```
Telegram is demoted from sole identity to one `ExternalIdentity` provider among several — it must remain fully functional, just no longer the only path.
## 2. Sprint 8.2 — VK ID (build first)
```
GET /api/identity/v1/vk/authorize -> redirects into VK's OAuth 2.1/PKCE flow
POST /api/identity/v1/vk/callback { code, codeVerifier } -> completes OAuth **backend-side**,
links ExternalIdentity, returns session
```
Invariants:
- OAuth completion happens entirely backend-side; the VK client secret never reaches the frontend.
- A repeat login for the same `providerUserId` must resolve to the same `Customer`, never create a duplicate.
- If `providerUserId` is already linked to a *different* `Customer` than the one currently authenticated (or none), this is an identity conflict — route to controlled resolution, never silently overwrite the existing binding (plan §14.3).
## 3. Sprint 8.3 — Email/phone OTP (after VK ID)
Implements the already-approved [email/phone login spec](../superpowers/specs/2026-08-15-email-phone-login-design.md). Per v3.1 §14, position this as **recovery/fallback** when a messenger channel is unavailable — not the primary login path. No new contract beyond that spec; this section exists only to fix its place in the build order relative to VK ID.
## 4. Sprint 8.4 — MAX + Telegram bot channels
```ts
interface BotConversationBinding {
customerId: string;
marketplaceId: string;
provider: 'telegram' | 'max';
chatId: string;
state: string; // see §5 state machine
orderId?: string;
lastMessageAt: string;
}
```
MAX linking flow (bot-assisted, one-time code):
```
POST /api/identity/v1/max/link-code -> { code, expiresAt } (TTL, single-use, bound to marketplace + browser session)
```
User opens the MAX bot, sends the code; a confirmed bot update on the backend calls:
```
POST /api/providers/v1/max/bot-webhook -- idempotent; a repeated update must not create a duplicate binding
```
which links the pending `Customer` session to the MAX `chatId`.
All three providers' incoming bot updates (VK, MAX, Telegram) normalize into one shape:
```ts
interface MessagingEvent {
provider: 'telegram' | 'vk' | 'max';
chatId: string;
orderId?: string;
text?: string;
receivedAt: string;
}
```
Provider bot tokens/secrets never reach the frontend, ever — only the backend calls each provider's Bot API.
## 5. Sprint 8.5 — Notification Orchestrator + Delivery Conversation State Machine
On `order.paid` (Phase 2 event bus), the orchestrator picks the customer's chosen channel (captured at checkout, see [Phase 6](PHASE-6-CART-CHECKOUT-CONTRACT.md) and `OrderContactSnapshot` in [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md)) and drives:
```
not_started -> awaiting_customer -> details_received -> manager_assigned/auto_confirmed -> shipment_planned -> completed
```
```ts
interface DeliveryDetailsSnapshot {
orderId: string;
city?: string;
address?: string;
recipientName?: string;
phone?: string;
timeWindow?: string;
comment?: string;
receivedAt: string;
}
```
Hard rules:
- **The bot never changes financial statuses.** It can only write `DeliveryDetailsSnapshot` fields via a dedicated Delivery Service — no bot code path touches `Order.paymentStatus`/`orderStatus`.
- The backoffice `Notification` (Phase 2 §6) fires unconditionally on `order.paid`, independent of whether the customer's messenger channel is reachable.
- If the chosen channel is unavailable, log a `DeliveryAttempt` error (Phase 2 §6) and fall back per tenant-configured policy (e.g. email/SMS) — never block the order itself.
- Follow-up messages are rate-limited per tenant policy; after the configured attempt limit, hand off to a human manager instead of continuing to message.
```
POST /api/providers/v1/{provider}/bot-webhook -- generic entrypoint for all three providers
GET /api/admin/v2/orders/{orderId}/conversation -- message history + current state, for manager handoff
POST /api/admin/v2/orders/{orderId}/conversation/handoff
```
## 6. What the frontend will start doing once this ships
- VK ID login button + OAuth redirect flow on storefront (primary social login).
- MAX/Telegram linking UI (one-time code flow).
- Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
- Manager-facing conversation view (message history, current delivery state, accept handoff).

View File

@@ -0,0 +1,130 @@
# Phase 9 Backend Contract — Tenant Registry, Domain Automation, Publish Model
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 9 (Sprints 9.19.3). Covers plan §4.3, §8.
**Status: ready to build.** Zero `hostinger` references exist in the codebase today.
---
## 1. Entities
```ts
interface Marketplace {
id: string;
name: string;
code: string;
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
ownerId: string;
countries: string[];
locales: string[];
currencies: string[];
timezone: string;
lifecycleState: MarketplaceLifecycleState;
}
type MarketplaceLifecycleState =
| 'draft' | 'configured' | 'content_ready' | 'domains_planned'
| 'staging_live' | 'qa_passed' | 'production_ready' | 'live' | 'paused' | 'archived';
interface MarketplaceDomain {
marketplaceId: string;
domain: string;
type: 'production' | 'www' | 'staging' | 'preview' | 'api' | 'seller';
status: 'planned' | 'dns_pending' | 'ssl_pending' | 'active' | 'failed';
}
interface MarketplaceFeatureSet {
marketplaceId: string;
features: Record<string, boolean>; // e.g. { catalog: true, sellers: true, cart: true, checkout: true, payments: true, orders: true, refunds: true, directory: false, ... }
}
interface MarketplaceRevision {
id: string;
marketplaceId: string;
status: 'draft' | 'validated' | 'preview' | 'published';
publishedAt?: string;
supersedesRevisionId?: string; // rollback creates a NEW revision, never mutates the old one
}
```
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every financial ledger row are **not part of a `MarketplaceRevision`**. Rolling back a storefront design revision must never touch commerce data.
## 2. Lifecycle state machine
```
draft -> configured -> content_ready -> domains_planned -> staging_live -> qa_passed -> production_ready -> live -> paused/archived
```
Every state transition endpoint must return the specific blocker preventing the next transition — not just "not ready."
```
GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers: string[] }
POST /api/admin/v2/marketplaces/{id}/lifecycle/advance
```
## 3. Onboarding wizard (8 steps, plan §4.3)
```
POST /api/admin/v2/marketplaces -- step 1: name/code/type/owner/countries/locales/currencies/timezone
PATCH /api/admin/v2/marketplaces/{id}/feature-set -- step 2
POST /api/admin/v2/marketplaces/{id}/domains -- step 3
PATCH /api/admin/v2/marketplaces/{id}/design -- step 4
POST /api/admin/v2/marketplaces/{id}/roles -- step 5
PATCH /api/admin/v2/marketplaces/{id}/integrations -- step 6
POST /api/admin/v2/marketplaces/{id}/staging-launch -- step 7, runs smoke tests
POST /api/admin/v2/marketplaces/{id}/production-launch -- step 8, requires all P0 blockers closed + explicit approval
```
## 4. Domain automation (Hostinger API, per plan §8.2)
```
GET /api/dns/v1/zones/{domain}
POST /api/dns/v1/zones/{domain}/validate
PUT /api/dns/v1/zones/{domain}
DELETE /api/dns/v1/zones/{domain}
GET /api/dns/v1/snapshots/{domain}
GET /api/dns/v1/snapshots/{domain}/{snapshotId}
POST /api/dns/v1/snapshots/{domain}/{snapshotId}/restore
```
Process, strictly in this order:
```
1. Read current DNS zone.
2. Save a snapshot (rollback payload) BEFORE any change.
3. Build and validate a DNS plan.
4. NEVER touch MX/SPF/DKIM/DMARC/CAA records without a separate, explicitly scoped task.
5. Apply records only after production approval.
6. Verify propagation, SSL issuance, and health checks.
7. Mark the domain 'active' only after all checks in step 6 pass.
```
## 5. Publish model
```
draft -> validation -> preview -> publish
```
```
POST /api/admin/v2/marketplaces/{id}/revisions -- create draft
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/validate
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/publish -- becomes immutable
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback -- creates a NEW revision pointing at the prior published content
```
Replaces the current builder's `localStorage`-only draft persistence and the empty `apiEndpoints.builder: {}` placeholder in bootstrap. CMS/static-page content (currently in-memory bootstrap only) gets a real write path through this same revision model.
## 6. Tenant resolution hardening
```
GET /api/v2/storefront/bootstrap -- resolved server-side from verified Host header
```
- Host is normalized and matched against `MarketplaceDomain` server-side — the marketplace ID from the browser is never a trust boundary.
- Unknown Host → `404`, with **no fallback to any other tenant**.
## 7. What the frontend will start doing once this ships
- Build the backoffice **Marketplaces** section (missing from admin nav today): registry, type, status, domains, currencies, feature set, responsible manager.
- Build the **Domains & Releases** section: DNS/SSL status, staging/production, health checks, rollback.
- Wire the project editor/builder to real revision persistence instead of `localStorage`.
- Marketplace dashboard: GMV, paid orders, conversion, payment failure rate, moderation queue, low stock, unmatched events, integration health, domain/SSL/release status (plan §4.2).

42
docs/backend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Backend Contracts Index — Product Plan v3.1
This directory is the complete set of wire contracts for building the backend behind [Product Plan v3.1](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Each doc specifies entities, endpoints, and invariants only — never DB schema or service boundaries, which stay backend's own call.
**Read order matches build order.** Every doc after Phase 1 depends on the ones before it (noted at the top of each). All Sprint 0.1 decisions referenced throughout were answered 2026-08-17 — see [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record.
## Launch-gate phases (P0 — required before production)
| Doc | Covers | Status |
|---|---|---|
| [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) | Money model, FX quote, price snapshot, server-authoritative checkout amount, payment state machine | Ready |
| [PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) | Canonical Order/OrderLine/Fulfillment (unified multi-seller), event bus, Notification Center | Ready |
| [PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | Product/Offer split, inventory/reservations, publish-time executability | Ready |
| [PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) | Generic external-order connector framework (no fixed marketplace list) | Ready |
## Post-launch-gate phases (P1/P2)
| Doc | Covers | Status |
|---|---|---|
| [PHASE-5-SELLER-PORTAL-CONTRACT.md](PHASE-5-SELLER-PORTAL-CONTRACT.md) | Seller org/user/membership, seller-scoped order/fulfillment views | Ready |
| [PHASE-6-CART-CHECKOUT-CONTRACT.md](PHASE-6-CART-CHECKOUT-CONTRACT.md) | Server-owned cart, checkout session | Ready |
| [PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) | Refunds, reconciliation, settlements | Ready |
| [PHASE-8-IDENTITY-MESSAGING-CONTRACT.md](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | Customer identity, VK ID (built first), OTP, MAX/Telegram bots, Notification Orchestrator | Ready |
| [PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) | Marketplace registry, Hostinger DNS automation, publish/revision model | Ready |
| [PHASE-10-CONTENT-MODULES-CONTRACT.md](PHASE-10-CONTENT-MODULES-CONTRACT.md) | Gorbushka-class mall/directory content entities | Ready, lowest priority |
## Cross-cutting tracks
| Doc | Covers | Status |
|---|---|---|
| [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) | Event pipeline, funnel, operational/quality metrics, synthetic-traffic separation | Ready — start alongside Phase 1, longest lead time |
| [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) | 17 roles/3 scopes, enforcement, audit log, secrets, rate limiting, step-up auth | Ready — gates the launch |
## What is deliberately not in this directory
- **API namespace migration** — Sprint 0.1 decision: new endpoints only use `/api/v2/...` etc; legacy endpoints (`/cart`, `/orders`, `/items`) are not being migrated as part of this contract set. See `BACKEND-API-REFERENCE.md` for the current live surface.
- **Per-connector adapters** (Ozon, Wildberries, etc.) — Sprint 0.1 decision: no fixed list. [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) §8 is the onboarding runbook; each partner's adapter is written when that partner is actually onboarded.
- **Additional payment providers** (wallets, BNPL) — open business decision, not yet made. [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §4.
## One open item across all of these
**Backend ownership is still unanswered** (Sprint 0.1). Every contract above is ready to hand to whoever builds it — that person/team just hasn't been named yet.

View File

@@ -0,0 +1,89 @@
# Track A Backend Contract — Analytics Event Pipeline
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Track A. Covers plan §3.1, §6.3, §13.3.
**Status: ready to build. Start alongside Phase 1, not last** — longest lead time in the programme, and it's a P0 in the plan's own §3.1. No tracking infrastructure exists at all today; this is missing infrastructure, not a missing endpoint.
---
## 1. Event logging spine
```ts
interface AnalyticsEvent {
eventType: string; // see §2-4 for the fixed vocabulary
marketplaceId: string;
sessionId: string;
customerId?: string;
timestamp: string;
properties: Record<string, unknown>;
isSynthetic: boolean; // see §6 - mandatory, never inferred
}
```
```
POST /api/v2/storefront/analytics/events { eventType, properties } -- server-side batched ingest
```
Frontend fires events client-side; backend is the source of truth for `sessionId` and `isSynthetic` — never trust a client-asserted synthetic flag without a matching signed staging/test-environment token.
## 2. Traffic events
```
session_started, page_view, product_view (with source/utm/referrer), unique users/sessions rollups
```
## 3. Catalog events
```
search, category_view, product_view, seller_view
```
## 4. Commerce events
```
add_to_cart, cart_view, checkout_started, payment_started, payment_success, payment_failed, order_created
```
These map directly onto the Phase 1/2/6 contracts' own state transitions — emit them from the same backend code paths that already produce `PaymentEvent`/`OrderEvent`, not a separately-maintained tracking layer that can drift.
## 5. Operational + quality metrics
```ts
interface OperationalMetric {
name: 'order_paid_to_notification_latency' | 'fulfillment_time' | 'connector_lag' | 'payment_webhook_lag';
marketplaceId: string;
value: number;
unit: 'seconds' | 'minutes';
measuredAt: string;
}
```
Quality events: frontend/backend errors, checkout validation failures, FX stale-rate blocks (Phase 1 §3.2).
## 6. Synthetic traffic separation (hard requirement, plan §3.1/§6.3/§10.2)
Synthetic/load-test traffic is permitted in staging and demo environments **only**, and must be technically inseparable-by-accident from production data — i.e. `isSynthetic: true` set server-side based on environment/token, never a client-settable flag that a real visit could accidentally or deliberately carry. Business reports must filter it out by construction, not by a manual exclusion query someone has to remember to add.
## 7. Endpoints
```
GET /api/admin/v2/analytics/funnel?marketplaceId=&period=
GET /api/admin/v2/analytics/operational?marketplaceId=&metric=
GET /api/admin/v2/analytics/quality?marketplaceId=
GET /api/v2/storefront/search/trending?marketplaceId= -- top N queries over a recent window, closes the existing SearchTrendingService.loadTrending() stub (returns of(null) today)
```
## 8. Post-launch monitoring set (plan §13.3, reuses the same event stream)
```
checkout_conversion, payment_success_failure_rate, webhook_processing_lag,
order_notification_lag, external_connector_lag, fx_quote_age_errors,
unmatched_reconciliation_count, fulfillment_stuck_count
```
## 9. What the frontend will start doing once this ships
- Replace the fully mock-composed `AdminAnalyticsFacade` with real funnel data.
- Fire the event vocabulary above from the relevant storefront interaction points.
- Bridge or replace the currently-always-zero `AdminProduct.visits` column with real tracking (see `GAPS-AND-IMPROVEMENTS.md`'s admin-product-views item — already partially speced in this session's [admin product views design](../superpowers/plans/2026-08-15-admin-product-views-column.md)).
- Wire `SearchTrendingService.loadTrending()` to the real endpoint in §7.

View File

@@ -0,0 +1,111 @@
# Track S Backend Contract — RBAC, Audit, Secrets, Rate Limiting
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Track S. Covers plan §4.4, §10.
**Status: ready to build. Gates the launch — this is the single most serious security gap identified in this session's audit.** Today the admin role model is decorative: `AdminRole` and permissions exist as types, but nothing gates any button, page, or action anywhere in the app. Any authenticated admin has full access.
---
## 1. Roles (17 total, 3 scopes, per plan §4.4)
```ts
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
type MarketplaceRole =
| 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER'
| 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
type SellerRole =
| 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
| 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
```
`SellerRole` is already specified in [Phase 5's contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) §4 — this doc adds the platform and marketplace scopes around it.
## 2. Enforcement (backend-side, non-negotiable)
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint must check `(role, tenantScope)` against the acting user's session — **before** touching data, not as a post-hoc filter. `tenant scope` here means: a `MARKETPLACE_ADMIN` for marketplace A must get a `403` (not an empty result) querying marketplace B's data, never a silently-scoped response that looks like "there's just nothing here."
```
GET /api/identity/v1/session/permissions -> { role, scopes: string[], marketplaceIds: string[] }
```
Frontend route/action guards derive from this endpoint's response — never hardcode role logic client-side beyond hiding UI affordances (which is convenience, not security).
## 3. Audit log
```ts
interface AuditEvent {
id: string;
actor: string;
action: string; // e.g. 'role.changed', 'offer.price_updated', 'refund.approved'
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
reason?: string;
occurredAt: string;
ip?: string;
}
```
Mandatory coverage (plan §10.1): permission changes, seller status changes, catalog moderation actions, price changes, payment/refund actions, manual order overrides, integration credential changes, production launch actions.
```
GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=
```
## 4. Secrets
All provider/connector credentials (payment providers, external marketplace connectors, VK/MAX/Telegram bot tokens, FX source keys) live in dedicated secret storage, referenced by opaque `credentialRef` strings in every other contract in this series — never returned in any API response body, never logged in plaintext.
## 5. Rate limiting
```
429 response: { error: { code: 'RATE_LIMITED', retryAfterSeconds: number } }
```
Applies to storefront/auth/provider endpoints. Frontend currently has **zero** 429 handling anywhere — see [BACKEND-API-REFERENCE.md §5](../../BACKEND-API-REFERENCE.md) for the full error-envelope contract this should follow.
## 6. Step-up authentication
Required before: bank/payment detail changes (Phase 5 §5), production launch (Phase 9 §3 step 8), role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, and any manual financial override (refund approval outside normal flow, price override on a live order).
## 7. PII minimization
Customer/seller PII is exposed only to roles that need it for their scope (e.g. `FINANCE_VIEWER` sees payout totals, not raw bank account numbers unless `FINANCE_MANAGER`+). Export endpoints (`GET .../export`) are themselves audit-logged actions per §3.
## 8. Initial admin provisioning & self-service admin management
Each marketplace ships with one bootstrap `MARKETPLACE_ADMIN` account, seeded at provisioning time (Phase 9 launch step):
- `login` = marketplace slug (`projectName`)
- `password` = `{projectName}2026$`, flagged `mustChangePassword: true`
- Login succeeds but every non-auth request 403s with `PASSWORD_CHANGE_REQUIRED` until password is changed.
```
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
```
A `MARKETPLACE_ADMIN` can then provision sub-admins scoped to their own marketplace only — mirrors the seller-team invite pattern in [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (`POST /api/seller/v1/team/invite`):
```
POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }
GET /api/admin/v2/team?marketplaceId=
PATCH /api/admin/v2/team/{userId} { role }
DELETE /api/admin/v2/team/{userId}
```
Invariants:
- `role` must be one of the `MarketplaceRole` set (§1) — never `PlatformRole`. Backend rejects any attempt to grant a platform-scope role through this endpoint (`403 SCOPE_ESCALATION_DENIED`).
- `marketplaceId` is forced server-side to the caller's own tenant scope — request body value is ignored/validated, never trusted.
- Every invite/role-change/removal is an audit-logged action (§3, `action: 'admin_team.invited' | 'admin_team.role_changed' | 'admin_team.removed'`).
- Role grants at `MARKETPLACE_ADMIN` level require step-up auth (§6).
- Invited admins get their own credentials (email + set-password flow), not the shared bootstrap login — the bootstrap account is for first login only and should be rotated/retired once real admins exist.
## 9. What the frontend will start doing once this ships
- Route guards and action-level permission checks across the entire backoffice — currently none exist.
- Backoffice **Audit & Security** section (missing from admin nav today): role changes, sensitive actions, login/security events, exports.
- Reconcile `AdminRole` (already de-duplicated to one canonical type this session) against the real 17-role table from §1.
- 429 interceptor + retry-after UI.

View File

@@ -0,0 +1,38 @@
---
id: ADR-0001
title: Extract auth and payment into shared @marketplaces packages
status: active
date: 2026-08-17
supersedes: []
tags: [architecture, auth, payment, monorepo]
---
# ADR-0001: Extract auth and payment into shared @marketplaces packages
## Context
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) and [Phase 7](../../backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
## Decision
Extract auth and payment client logic into two standalone, independently versioned npm packages:
- `@marketplaces/auth` — customer auth (VK ID/OTP/session), admin auth (ed25519 verification, permission guards, interceptors), token/session management.
- `@marketplaces/payment` — payment/finance client gateways, FX/pricing models, checkout client contracts (thin — business logic stays backend per Phase 1/7).
Each package:
1. Lives in its own git repo (handed over separately; this repo does not host it long-term).
2. Is consumed by `marketplaces` (and other projects) as an installed node_modules dependency — imported, never copy-pasted.
3. Is versioned with semver; CI on the package repo auto-bumps and publishes on push to `main`, driven by conventional commit prefixes already used in this repo (`feat:`/`fix:`/etc — semantic-release reads these directly).
4. Ships with its own test suite; `marketplaces` treats it as a black-box dependency, not source to edit in place.
Rollout order: scaffold packages and CI in this repo first (reversible, local-only) → hand over target git repo → publish → migrate `marketplaces` call sites to import from the package → delete the in-repo originals only after the app builds and passes tests against the package.
## Consequences
- `marketplaces` loses direct edit access to auth/payment source — changes go through the package's own repo/PR/release cycle. Slower iteration, but consistent behavior across all consuming projects.
- ~30 call sites in `marketplaces` (see `core/auth`, `core/admin-auth`, `services/auth.service.ts`, interceptors) need import rewiring during migration — tracked as follow-up work, not done in this ADR.
- New failure mode: package registry/CI outage blocks `marketplaces` builds if a version bump lands mid-incident. Pin exact versions, do not use floating ranges, to keep this bounded.
- [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.

View File

@@ -4,3 +4,4 @@
{"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]} {"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]}
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]} {"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]} {"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}

View File

@@ -0,0 +1,137 @@
# Platform Super-Admin — Phase 1 Design
**Status:** Approved
**Date:** 2026-08-15
**Audience:** Internal admin & risk team ("super puper user")
## Purpose
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
platform, drill into one, and review its access list, audit log, admin edit history, and
purchase history. Read-only in this phase.
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
this store's admin" toggle) is explicitly **out of scope** for this phase — see
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
notify plumbing depends on the tenant-context switch this phase builds.
## Non-goals (Phase 1)
- No editing of any tenant's data.
- No impersonation of a store's admin.
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
don't exist yet).
- No real backend — this repo is frontend-only; the backend contract is specified here for
whoever owns that service, not implemented here.
## Architecture
- New top-level feature module: `src/app/features/platform-admin/`.
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
`/admin/**` — a project is not "logged into" the way a store admin is.
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
`core/admin-auth/admin-auth.guard.ts`).
- `PlatformAuthService` — session/login state for the super-admin, backed by a
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
never committed, never present in a production build path.
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
interface, no caller changes needed.
## Data model
```ts
interface PlatformProjectSummary {
id: UUID;
name: string;
slug: string;
host: string;
status: 'active' | 'suspended';
createdAt: number;
adminCount: number;
lastActivityAt: number | null;
}
interface PlatformProjectAccessEntry {
userId: UUID;
displayName: string;
telegramUsername: string;
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
}
type PlatformProjectHistoryEntry =
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
```
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
aggregation is a backend concern later.
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
`core/auth/models/permission.model.ts` — no new role system.
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
aggregation from existing per-tenant sources (e.g. the pattern in
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
changed.
## Components / pages
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
last activity. Search/filter by status.
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
- **Audit Log** — `history` filtered to `kind: 'access'`.
- **Edit History** — `history` filtered to `kind: 'edit'`.
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
- All read-only in this phase.
## Security
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
enforcement must happen server-side once the backend endpoint exists. This is called out
explicitly so it's never mistaken for the source of truth.
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
git-ignored local file; production auth goes through the real backend endpoint below.
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
sessions — higher-privilege session, smaller blast radius if a session is left open).
- Every super-admin action (including read-only views) is itself audit-logged.
- After implementation, run `/security-audit` on this feature specifically before it ships.
### Backend contract (for whoever owns that service — not implemented in this repo)
Add to `BACKEND-API-REFERENCE.md`:
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
artifact.
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
that tenant, paginated.
## Testing
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
mapping logic.
- No E2E in this phase — no real backend to exercise end-to-end yet.
## Phase 2 (out of scope here)
A separate spec/plan cycle, once Phase 1 ships:
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
module (products, orders, categories, settings, etc.), reusing those modules under a
tenant-context switch.
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
unchecked**. Uses the existing in-app notification pattern (the one behind
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
in their notification feed. Unchecked-by-default matters: some super-admin edits are
discreet technical fixes where alerting the store admin would be noise or a reputational
concern, not every edit should ping them.
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
establishes, which is why it's sequenced after.

1106
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@marketplaces/auth": "0.1.0",
"@angular/animations": "22.0.8", "@angular/animations": "22.0.8",
"@angular/cdk": "22.0.6", "@angular/cdk": "22.0.6",
"@angular/common": "22.0.8", "@angular/common": "22.0.8",

12
renovate.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchPackageNames": ["@marketplaces/auth", "@marketplaces/payment"],
"groupName": "marketplaces shared packages",
"automerge": false,
"labels": ["shared-package-update"]
}
]
}

View File

@@ -7,12 +7,11 @@ import { cacheInterceptor } from './interceptors/cache.interceptor';
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; 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 } from './core/admin-auth/admin-auth-headers.interceptor'; import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model';
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
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 { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -25,6 +24,8 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(withXhr(), provideHttpClient(withXhr(),
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor]) withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
), ),
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService }, { provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
{ provide: MediaRepository, useClass: MockMediaRepository }, { provide: MediaRepository, useClass: MockMediaRepository },
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {

View File

@@ -1,7 +1,8 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { languageGuard } from './guards/language.guard'; import { languageGuard } from './guards/language.guard';
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard'; import { adminAuthGuard } from '@marketplaces/auth';
import { requireAdminPermission } from './core/admin-auth/admin-auth.guard';
import { authRoutes } from './core/auth/auth.routes'; import { authRoutes } from './core/auth/auth.routes';
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard'; import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
@@ -191,6 +192,51 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }] breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
} }
}, },
{
path: 'notifications',
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
data: {
titleKey: 'adminShell.nav.notifications',
descriptionKey: 'adminShell.nav.notifications',
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
}
},
{
path: 'integrations',
loadComponent: () => import('./features/admin/integrations/pages/admin-integrations-page.component').then(m => m.AdminIntegrationsPageComponent),
data: {
titleKey: 'adminShell.nav.integrations',
descriptionKey: 'adminShell.nav.integrations',
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
}
},
{
path: 'finance',
loadComponent: () => import('./features/admin/finance/pages/admin-finance-page.component').then(m => m.AdminFinancePageComponent),
data: {
titleKey: 'adminShell.nav.finance',
descriptionKey: 'adminShell.nav.finance',
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
}
},
{
path: 'marketplaces',
loadComponent: () => import('./features/admin/marketplaces/pages/admin-marketplaces-page.component').then(m => m.AdminMarketplacesPageComponent),
data: {
titleKey: 'adminShell.nav.marketplaces',
descriptionKey: 'adminShell.nav.marketplaces',
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
}
},
{
path: 'audit',
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
data: {
titleKey: 'adminShell.nav.audit',
descriptionKey: 'adminShell.nav.audit',
breadcrumb: [{ labelKey: 'adminShell.nav.audit' }]
}
},
{ {
path: 'moderation', path: 'moderation',
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent), loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),

View File

@@ -16,8 +16,7 @@ import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
import { ApiHealthService } from './services/api-health.service'; import { ApiHealthService } from './services/api-health.service';
import { SeoService } from './services/seo.service'; import { SeoService } from './services/seo.service';
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component'; import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
import { AdminAuthService } from './core/admin-auth/admin-auth.service'; import { AdminAuthService, AuthService } from '@marketplaces/auth';
import { AuthService } from './services/auth.service';
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component'; import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
@Component({ @Component({

View File

@@ -6,7 +6,7 @@ import { of } from 'rxjs';
import { BootstrapConfig } from '../../shared/models/config'; import { BootstrapConfig } from '../../shared/models/config';
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token'; import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
import { ConfigService } from '../../core/config/config.service'; import { ConfigService } from '../../core/config/config.service';
import { AuthService } from '../../services/auth.service'; import { AuthService, AUTH_API_URL } from '@marketplaces/auth';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';
function makeBootstrap(): BootstrapConfig { function makeBootstrap(): BootstrapConfig {
@@ -52,6 +52,7 @@ describe('HeaderComponent profile control (login/logout gating regression)', ()
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } }, { provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
{ provide: AUTH_API_URL, useValue: 'https://test.local' },
{ provide: AuthService, useValue: fakeAuth }, { provide: AuthService, useValue: fakeAuth },
], ],
}); });

View File

@@ -14,7 +14,7 @@ import { FeatureConfigService } from '../../core/config/feature-config.service';
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
import { IconComponent } from '../../shared/ui/icon/icon.component'; import { IconComponent } from '../../shared/ui/icon/icon.component';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '@marketplaces/auth';
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component'; import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
@Component({ @Component({

View File

@@ -1,12 +1,10 @@
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core'; import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service'; import { AuthService, AdminAuthService, AuthSession } from '@marketplaces/auth';
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine'; import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model'; import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
import { AuthSession } from '../../models/auth.model';
import { IconComponent } from '../../shared/ui/icon/icon.component'; import { IconComponent } from '../../shared/ui/icon/icon.component';
/** /**

View File

@@ -0,0 +1,4 @@
<button type="button" class="vk-id-login" [disabled]="loading()" (click)="startLogin()">
<app-icon name="user" [size]="18" />
<span>Continue with VK ID</span>
</button>

View File

@@ -0,0 +1,19 @@
.vk-id-login {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { take } from 'rxjs/operators';
import { VK_ID_GATEWAY } from '../../core/identity/services/vk-id-gateway.token';
import { IconComponent } from '../../shared/ui/icon/icon.component';
/**
* Standalone VK ID login button, per Sprint 0.1 ("do all after vk" - VK ID
* is the primary storefront social login going forward, per v3.1 §14).
* Deliberately not wired into TelegramLoginComponent's dialog yet - that
* component is the live, working customer/admin login surface, and
* splicing a second provider into it needs its own careful pass once a
* real VK OAuth app exists to test against, not a mock-backed bolt-on.
*/
@Component({
selector: 'app-vk-id-login',
standalone: true,
imports: [CommonModule, IconComponent],
templateUrl: './vk-id-login.component.html',
styleUrls: ['./vk-id-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VkIdLoginComponent {
private readonly gateway = inject(VK_ID_GATEWAY);
readonly loading = signal(false);
startLogin(): void {
this.loading.set(true);
this.gateway.getAuthorizeUrl().pipe(take(1)).subscribe(url => {
this.loading.set(false);
if (typeof window !== 'undefined') {
window.location.href = url;
}
});
}
}

View File

@@ -1,33 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AdminAuthService } from './admin-auth.service';
/** Backend paths that require an active AdminWebSessionID per API-REFERENCE.md §0. */
const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/'];
/**
* Attaches admin session/token headers only to admin API requests. Mirrors
* apiHeadersInterceptor's self-guarding pattern but scoped to admin-gated
* paths so it never touches customer requests and never reads AuthService's
* session.
*/
export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => {
const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment));
if (!isAdminRequest) {
return next(req);
}
const adminAuth = inject(AdminAuthService);
const session = adminAuth.session();
const token = adminAuth.getAdminToken();
let headers = req.headers;
if (session?.sessionId) {
headers = headers.set('AdminWebSessionID', session.sessionId);
}
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
return next(req.clone({ headers }));
};

View File

@@ -1,24 +1,14 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router'; import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from '@marketplaces/auth';
import { AdminPermissionsService } from './admin-permissions.service'; import { AdminPermissionsService } from './admin-permissions.service';
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => {
const adminAuth = inject(AdminAuthService);
if (adminAuth.isAuthenticated()) {
return true;
}
adminAuth.requestLogin();
return false;
};
/** /**
* UI-only gate for a specific permission, on top of adminAuthGuard's * UI-only gate for a specific permission, on top of the package's
* authentication check. See AdminPermissionsService for why this is * adminAuthGuard authentication check. See AdminPermissionsService for why
* cosmetic until the backend ships real admin-role enforcement. * this is cosmetic until the backend ships real admin-role enforcement.
* Kept app-local because it depends on AdminPermissionsService, which reads
* this app's mock Users domain - not a portable auth concern.
*/ */
export function requireAdminPermission(permission: string): CanActivateFn { export function requireAdminPermission(permission: string): CanActivateFn {
return () => { return () => {

View File

@@ -1,212 +0,0 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AdminAuthStatus } from '../../models/admin-auth.model';
import { AuthSession, WebSessionStart } from '../../models/auth.model';
import { TelegramSessionApiService } from '../../services/telegram-session-api.service';
import { environment } from '../../../environments/environment';
/**
* Admin login uses the exact same Telegram QR/session API as the customer
* login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is
* no separate admin backend endpoint, and none should be invented client-side.
* Only the *storage* is kept separate from AuthService, so an admin QR scan
* never authenticates the customer session or vice versa: distinct cookie
* name, distinct signals, distinct guard/interceptor.
*
* Backend gap this creates (see docs/backend/BACKEND-INTEGRATION.md §2.5): since the session
* API itself has no concept of "admin", the frontend cannot tell an admin
* Telegram session from a regular one. Actual admin authorization must be
* enforced server-side when admin API calls are made with the resulting
* session id - the frontend only decides where to *store* the result.
*/
const ADMIN_SESSION_COOKIE = 'adminSessionID';
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken';
const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
private readonly api = inject(TelegramSessionApiService);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
private readonly showLoginSignal = signal(false);
readonly session = this.sessionSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly showLoginDialog = this.showLoginSignal.asReadonly();
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
constructor() {
this.checkSession();
}
checkSession(): void {
const webSessionID = this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.statusSignal.set('checking');
this.checkSessionOnce(webSessionID).subscribe(session => {
if (!session?.active) {
this.clearAuthState('unauthenticated');
}
});
}
/** Check session without mutating internal state beyond activating on success (used for polling). */
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
return this.api.checkSessionOnce(webSessionID).pipe(
tap(session => {
if (session?.active) {
this.activateSession(session);
}
})
);
}
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
createWebSession(): Observable<WebSessionStart> {
return this.api.createSession();
}
getAdminAppLoginUrl(webSessionID: string): string {
return this.api.getBotAppLoginUrl(webSessionID);
}
onLoginComplete(): void {
this.hideLogin();
if (!this.isAuthenticated()) {
this.checkSession();
}
}
requestLogin(): void {
this.showLoginSignal.set(true);
}
/**
* Dev-only shortcut for local testing without a reachable Telegram/session
* backend: fabricates a local session and activates it directly, skipping
* the QR flow entirely. No-ops in production builds (checked at runtime,
* not just build-time, so it is safe even if this code ships). Never call
* this from anywhere reachable in a production build.
*/
devBypassLogin(): void {
if (environment.production) {
return;
}
this.hideLogin();
this.activateSession({
sessionId: `dev-bypass-${Date.now()}`,
userId: 0,
username: 'dev-admin',
displayName: 'Dev Admin (local bypass)',
active: true,
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}
hideLogin(): void {
this.showLoginSignal.set(false);
}
logout(): void {
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
}
setAdminTokens(token: string, refreshToken: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken);
}
clearAdminTokens(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
}
private activateSession(session: AuthSession): void {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.setStoredAdminSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expires);
}
private clearAuthState(status: AdminAuthStatus): void {
this.sessionSignal.set(null);
this.statusSignal.set(status);
this.clearStoredAdminSessionID();
this.clearAdminTokens();
this.clearSessionRefresh();
}
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
const refreshIn = Number.isFinite(expiresMs)
? Math.max(expiresMs - nowMs - 60_000, 30_000)
: ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
private getStoredAdminSessionID(): string | null {
if (typeof document === 'undefined') {
return null;
}
const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!cookie) {
return null;
}
try {
return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1));
} catch {
return null;
}
}
private setStoredAdminSessionID(webSessionID: string): void {
if (typeof document === 'undefined') {
return;
}
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`;
}
private clearStoredAdminSessionID(): void {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, computed, inject } from '@angular/core'; import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from '@marketplaces/auth';
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway'; import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
/** /**

View File

@@ -1,31 +0,0 @@
import { Observable } from 'rxjs';
/**
* Prep interfaces for a future Ed25519 challenge/response admin auth flow.
* No crypto is implemented here - verification is delegated to an injectable
* service so the real implementation (native WebCrypto Ed25519 support, or a
* backend verification call) can be swapped in once the backend API exists,
* without touching AdminAuthService or components.
*/
export interface Ed25519Challenge {
nonce: string;
timestamp: string;
/** Opaque challenge payload the client must sign with its private key. */
payload: string;
}
export interface Ed25519SignedResponse {
challenge: Ed25519Challenge;
publicKey: string;
signature: string;
}
export interface Ed25519VerificationResult {
valid: boolean;
reason?: string;
}
export abstract class Ed25519VerificationService {
abstract requestChallenge(): Observable<Ed25519Challenge>;
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -1,20 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model';
/**
* Default DI binding for Ed25519VerificationService until the backend ships
* the real challenge/verify endpoints. Intentionally fails closed (throws)
* rather than pretending to verify anything, so accidental use in a login
* path is loud instead of silently accepting unsigned sessions.
*/
@Injectable({ providedIn: 'root' })
export class NoopEd25519VerificationService implements Ed25519VerificationService {
requestChallenge(): Observable<Ed25519Challenge> {
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
}
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
}
}

View File

@@ -0,0 +1,11 @@
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1-4. */
export type AnalyticsEventType =
| 'session_started' | 'page_view' | 'search' | 'category_view' | 'product_view' | 'seller_view'
| 'add_to_cart' | 'cart_view' | 'checkout_started'
| 'payment_started' | 'payment_success' | 'payment_failed' | 'order_created';
export interface AnalyticsEvent {
eventType: AnalyticsEventType;
properties: Record<string, unknown>;
isSynthetic: boolean;
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export interface AnalyticsGateway {
track(event: AnalyticsEvent): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AnalyticsGateway } from './analytics-gateway.interface';
import { AnalyticsLocalGateway } from './analytics-local.gateway';
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AnalyticsLocalGateway),
});

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/**
* No tracking pipeline exists at all today (confirmed - this is missing
* infrastructure, not a missing endpoint, per GAPS-AND-IMPROVEMENTS.md and
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md). This mock only proves the
* call-site wiring is correct; it does not persist anything.
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsLocalGateway implements AnalyticsGateway {
track(_event: AnalyticsEvent): Observable<void> {
return of(void 0);
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs/operators';
import { AnalyticsEventType } from '../models/analytics-event.model';
import { ANALYTICS_GATEWAY } from './analytics-gateway.token';
import { environment } from '../../../../environments/environment';
/**
* Thin call-site wrapper so storefront components fire events without
* knowing about the gateway/token plumbing. isSynthetic is derived from the
* build environment, never client-settable at the call site (per
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §6 - synthetic traffic must be
* inseparable-by-accident from production data).
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly gateway = inject(ANALYTICS_GATEWAY);
track(eventType: AnalyticsEventType, properties: Record<string, unknown> = {}): void {
this.gateway.track({
eventType,
properties,
isSynthetic: !environment.production,
}).pipe(take(1)).subscribe();
}
}

View File

@@ -1,46 +0,0 @@
import { AdminRole } from './permission.model';
/**
* Wire contracts for the Ed25519 challenge/response admin auth flow. These
* are documented in docs/AUTH.md and match the endpoints listed there
* exactly - none of this is invented beyond what's documented as FUTURE
* there and in docs/backend/BACKEND-INTEGRATION.md §2.5.
*/
export interface AuthChallenge {
nonce: string;
/** ISO 8601 issue time of the challenge. */
issuedAt: string;
/** ISO 8601 - challenge must be used before this or the backend rejects it. */
expiresAt: string;
}
export interface VerifySignatureRequest {
publicKey: string;
signature: string;
nonce: string;
}
export interface AuthTokenPair {
token: string;
refreshToken: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
/**
* Claims expected in the JWT `token`. Decoded client-side for display/UX
* only (role-gating UI, expiry countdown) - the frontend never treats this
* as proof of authorization; every admin request is still re-checked
* server-side per docs/AUTH.md security considerations.
*/
export interface JwtClaims {
sub: string;
role: AdminRole;
/** Issued-at, seconds since epoch (standard `iat` claim). */
iat: number;
/** Expiry, seconds since epoch (standard `exp` claim). */
exp: number;
publicKey: string;
}

View File

@@ -1,50 +0,0 @@
/**
* Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to
* a dedicated screen (see `core/auth/pages`) rather than a generic toast,
* because the recovery action differs per code (re-login vs. retry vs. wait).
*/
export type AuthErrorCode =
| 'session-expired'
| 'invalid-signature'
| 'unauthorized'
| 'forbidden'
| 'backend-unavailable';
export interface AuthError {
code: AuthErrorCode;
message: string;
/** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */
status?: number;
}
/**
* Maps the backend error envelope's `error.code` (see
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
* Only codes with a dedicated screen are mapped; anything else falls back
* to the HTTP-status-derived code via authErrorCodeFromStatus.
*/
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
}
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
switch (status) {
case 401:
return 'unauthorized';
case 403:
return 'forbidden';
case 0:
return 'backend-unavailable';
default:
return status >= 500 ? 'backend-unavailable' : 'unauthorized';
}
}

View File

@@ -1,30 +0,0 @@
/**
* Roles the Ed25519 JWT `role` claim is expected to carry (see docs/AUTH.md
* §JWT Claims). Ordered highest-to-lowest privilege; PermissionService does
* not rely on the order, it is documentation only.
*/
export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
/**
* Coarse-grained permission keys. Intentionally small and domain-agnostic
* (mirrors the existing bootstrap-level `PermissionsConfig` shape in
* `shared/models/config/permissions.model.ts`) - fine-grained, per-domain
* permissions stay server-side until the backend ships a real permission
* model; the frontend only needs enough to hide/disable UI, never to be the
* source of truth for authorization.
*/
export type Permission =
| 'backoffice.read'
| 'backoffice.write'
| 'builder.read'
| 'builder.write'
| 'users.manage'
| 'settings.manage';
export const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>> = {
Owner: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage', 'settings.manage'],
Administrator: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage'],
Editor: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write'],
Support: ['backoffice.read'],
ReadOnly: ['backoffice.read', 'builder.read']
};

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { AuthFacade } from '../services/auth-facade.service'; import { AuthFacade, Ed25519KeypairService } from '@marketplaces/auth';
import { Ed25519KeypairService } from '../services/ed25519-keypair.service';
/** /**
* Ed25519 admin login page. Prepared UI for the flow described in * Ed25519 admin login page. Prepared UI for the flow described in

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { AuthErrorCode } from '../models/auth-error.model'; import { AuthErrorCode } from '@marketplaces/auth';
interface AuthErrorCopy { interface AuthErrorCopy {
title: string; title: string;

View File

@@ -1,36 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from '../models/auth-api.model';
/**
* Thin HTTP client for the Ed25519 admin auth endpoints documented in
* docs/AUTH.md. These endpoints do not exist on the backend yet (FUTURE -
* see docs/backend/BACKEND-INTEGRATION.md §2.5) - calling them today 404s
* or connection-errors, which AuthService maps to the
* `backend-unavailable` error screen. No mock/fake responses are fabricated
* here; this is real HttpClient wiring against the real contract, ready for
* the moment the backend ships.
*/
@Injectable({ providedIn: 'root' })
export class AuthApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.authApiUrl}/api/admin/auth`;
requestChallenge(): Observable<AuthChallenge> {
return this.http.get<AuthChallenge>(`${this.baseUrl}/challenge`);
}
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/verify`, request);
}
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/refresh`, request);
}
logout(refreshToken: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest);
}
}

View File

@@ -1,56 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { AuthService } from './auth.service';
import { PermissionService } from './permission.service';
import { SessionService } from './session.service';
import { Permission } from '../models/permission.model';
/**
* Public surface for components/pages. Components should depend on this,
* not on AuthService/SessionService/PermissionService directly, so the
* orchestration details (which service owns what) can change without
* touching UI code.
*/
@Injectable({ providedIn: 'root' })
export class AuthFacade {
private readonly auth = inject(AuthService);
private readonly session = inject(SessionService);
private readonly permissions = inject(PermissionService);
private readonly router = inject(Router);
readonly isAuthenticated = this.session.isAuthenticated;
readonly status = this.session.status;
readonly role = this.session.role;
readonly loginPhase = this.auth.loginPhase;
readonly lastError = this.auth.lastError;
restoreSession(): void {
this.auth.restoreSession();
}
login(onSuccessRedirectTo?: string): void {
this.auth.login().subscribe({
next: () => {
if (onSuccessRedirectTo) {
this.router.navigateByUrl(onSuccessRedirectTo);
}
},
error: () => {
const code = this.auth.lastError()?.code ?? 'unauthorized';
this.router.navigate(['/admin-login/error', code]);
}
});
}
logout(redirectTo = '/admin-login'): void {
this.auth
.logout()
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
.subscribe({ error: () => undefined });
}
can(permission: Permission): boolean {
return this.permissions.has(permission);
}
}

View File

@@ -1,126 +0,0 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthTokenPair } from '../models/auth-api.model';
import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service';
export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done';
/**
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
*
* GET /api/admin/auth/challenge -> { nonce }
* sign(nonce) with local Ed25519 key -> signature
* POST /api/admin/auth/verify -> { token, refreshToken }
*
* This is the lowest-level orchestrator; components should go through
* AuthFacade rather than calling this directly.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly api = inject(AuthApiService);
private readonly keypair = inject(Ed25519KeypairService);
private readonly session = inject(SessionService);
private readonly loginPhaseSignal = signal<LoginPhase>('idle');
private readonly lastErrorSignal = signal<AuthError | null>(null);
readonly loginPhase = this.loginPhaseSignal.asReadonly();
readonly lastError = this.lastErrorSignal.asReadonly();
constructor() {
this.session.onRefreshDue(() => this.refresh().subscribe());
}
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
restoreSession(): void {
this.session.restore();
}
login(): Observable<AuthTokenPair> {
this.lastErrorSignal.set(null);
this.loginPhaseSignal.set('requesting-challenge');
return this.api.requestChallenge().pipe(
switchMap(challenge =>
this.signChallenge(challenge.nonce).pipe(
switchMap(({ publicKeyBase64, signature }) => {
this.loginPhaseSignal.set('verifying');
return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce });
})
)
),
tap(tokens => {
this.session.activate(tokens);
this.loginPhaseSignal.set('done');
}),
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'invalid-signature'))
);
}
refresh(): Observable<AuthTokenPair> {
const refreshToken = this.session.getRefreshToken();
if (!refreshToken) {
this.session.markExpired();
return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' }));
}
return this.api.refresh({ refreshToken }).pipe(
tap(tokens => this.session.activate(tokens)),
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'session-expired', () => this.session.markExpired()))
);
}
logout(): Observable<void> {
const refreshToken = this.session.getRefreshToken();
this.session.clear();
if (!refreshToken) {
return new Observable<void>(subscriber => {
subscriber.next();
subscriber.complete();
});
}
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
}
private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> {
this.loginPhaseSignal.set('signing');
return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => {
this.keypair
.getOrCreateKeyPair()
.then(({ publicKeyBase64 }) =>
this.keypair.sign(nonce).then(signature => {
subscriber.next({ publicKeyBase64, signature });
subscriber.complete();
})
)
.catch(error => subscriber.error(error));
});
}
private handleAuthError<T>(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable<T> {
onError?.();
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
}
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
if (error instanceof HttpErrorResponse) {
const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
return { code, message: error.message, status: error.status };
}
if (error instanceof Error) {
return { code: fallbackCode, message: error.message };
}
return { code: fallbackCode, message: 'Unknown authentication error.' };
}
private toAuthError(error: AuthError): AuthError {
this.lastErrorSignal.set(error);
return error;
}
}

View File

@@ -1,125 +0,0 @@
import { Injectable } from '@angular/core';
/**
* Manages the browser-local Ed25519 keypair used to sign admin auth
* challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen
* browsers) - not a placeholder. The private key is generated
* non-extractable and kept only in IndexedDB as a CryptoKey handle; it is
* never serialized, never sent anywhere, and cannot be exported by design.
*
* Registering `publicKey` with an admin's account (associating it with a
* role) is a backend-side, out-of-band operation (e.g. an Owner approving a
* new admin's public key) - entirely outside this frontend's scope.
*/
const DB_NAME = 'admin-auth-ed25519';
const DB_VERSION = 1;
const STORE_NAME = 'keypair';
const KEY_RECORD_ID = 'device-keypair';
interface StoredKeyPair {
id: string;
publicKey: CryptoKey;
privateKey: CryptoKey;
publicKeyBase64: string;
}
@Injectable({ providedIn: 'root' })
export class Ed25519KeypairService {
private cached: StoredKeyPair | null = null;
isSupported(): boolean {
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
}
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> {
if (!this.isSupported()) {
throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).');
}
const existing = await this.loadFromStore();
if (existing) {
this.cached = existing;
return { publicKeyBase64: existing.publicKeyBase64 };
}
const generated = await this.generateAndPersist();
this.cached = generated;
return { publicKeyBase64: generated.publicKeyBase64 };
}
async sign(message: string): Promise<string> {
const keyPair = this.cached ?? (await this.loadFromStore());
if (!keyPair) {
throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.');
}
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message));
return this.toBase64(new Uint8Array(signatureBuffer));
}
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
async clear(): Promise<void> {
this.cached = null;
const db = await this.openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
private async generateAndPersist(): Promise<StoredKeyPair> {
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair;
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
const record: StoredKeyPair = {
id: KEY_RECORD_ID,
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
publicKeyBase64
};
const db = await this.openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return record;
}
private async loadFromStore(): Promise<StoredKeyPair | null> {
const db = await this.openDatabase();
return new Promise<StoredKeyPair | null>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null);
request.onerror = () => reject(request.error);
});
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
private toBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
}

View File

@@ -1,44 +0,0 @@
import { Injectable } from '@angular/core';
import { JwtClaims } from '../models/auth-api.model';
/**
* Client-side JWT *decoding* only - never verification. The signature is
* meaningless to check here because the frontend has no trusted key to check
* it against; verifying a JWT's signature is the backend's job on every
* request. This service exists purely so the UI can read `role`/`exp` for
* display and route-gating UX (e.g. "session expires in 4m").
*/
@Injectable({ providedIn: 'root' })
export class JwtService {
decode(token: string): JwtClaims | null {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const payload = this.base64UrlDecode(parts[1]);
const claims = JSON.parse(payload) as JwtClaims;
return this.isJwtClaims(claims) ? claims : null;
} catch {
return null;
}
}
isExpired(claims: JwtClaims, skewSeconds = 0): boolean {
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
}
private isJwtClaims(value: unknown): value is JwtClaims {
if (!value || typeof value !== 'object') {
return false;
}
const claims = value as Partial<JwtClaims>;
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
}
private base64UrlDecode(input: string): string {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
return decodeURIComponent(escape(atob(base64)));
}
}

View File

@@ -1,26 +0,0 @@
import { Injectable, computed, inject } from '@angular/core';
import { Permission, ROLE_PERMISSIONS } from '../models/permission.model';
import { SessionService } from './session.service';
/**
* Derives the current admin's permission set from their JWT `role` claim.
* UI-only gate (hide/disable) - the backend must independently enforce
* every mutation server-side; see docs/AUTH.md security considerations.
*/
@Injectable({ providedIn: 'root' })
export class PermissionService {
private readonly session = inject(SessionService);
readonly permissions = computed<readonly Permission[]>(() => {
const role = this.session.role();
return role ? ROLE_PERMISSIONS[role] : [];
});
has(permission: Permission): boolean {
return this.permissions().includes(permission);
}
hasAny(permissions: readonly Permission[]): boolean {
return permissions.some(permission => this.has(permission));
}
}

View File

@@ -1,133 +0,0 @@
import { Injectable, computed, signal } from '@angular/core';
import { AuthTokenPair, JwtClaims } from '../models/auth-api.model';
import { JwtService } from './jwt.service';
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
const TOKEN_STORAGE_KEY = 'ed25519AdminToken';
const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken';
/** Refresh this long before actual expiry, so a request never races an expiring token. */
const REFRESH_SKEW_MS = 60_000;
/**
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
* from AdminAuthService (Telegram-session state) by design - the two auth
* mechanisms are not merged until the backend actually ships the Ed25519
* endpoints and a migration decision is made (see docs/AUTH.md).
*/
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly jwt = new JwtService();
private readonly tokenSignal = signal<string | null>(null);
private readonly refreshTokenSignal = signal<string | null>(null);
private readonly claimsSignal = signal<JwtClaims | null>(null);
private readonly statusSignal = signal<SessionStatus>('unknown');
readonly token = this.tokenSignal.asReadonly();
readonly claims = this.claimsSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly role = computed(() => this.claimsSignal()?.role ?? null);
private refreshTimer?: ReturnType<typeof setTimeout>;
private refreshCallback?: () => void;
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
onRefreshDue(callback: () => void): void {
this.refreshCallback = callback;
}
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
restore(): boolean {
this.statusSignal.set('restoring');
const token = this.readStorage(TOKEN_STORAGE_KEY);
const refreshToken = this.readStorage(REFRESH_STORAGE_KEY);
if (!token || !refreshToken) {
this.statusSignal.set('unauthenticated');
return false;
}
const claims = this.jwt.decode(token);
if (!claims) {
this.clear();
return false;
}
this.tokenSignal.set(token);
this.refreshTokenSignal.set(refreshToken);
this.claimsSignal.set(claims);
if (this.jwt.isExpired(claims)) {
this.statusSignal.set('expired');
} else {
this.statusSignal.set('authenticated');
this.scheduleRefresh(claims);
}
return true;
}
activate(tokens: AuthTokenPair): void {
const claims = this.jwt.decode(tokens.token);
if (!claims) {
throw new Error('Received a malformed JWT from the auth backend.');
}
this.tokenSignal.set(tokens.token);
this.refreshTokenSignal.set(tokens.refreshToken);
this.claimsSignal.set(claims);
this.statusSignal.set('authenticated');
this.writeStorage(TOKEN_STORAGE_KEY, tokens.token);
this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken);
this.scheduleRefresh(claims);
}
getRefreshToken(): string | null {
return this.refreshTokenSignal();
}
markExpired(): void {
this.statusSignal.set('expired');
this.clearRefreshTimer();
}
clear(): void {
this.tokenSignal.set(null);
this.refreshTokenSignal.set(null);
this.claimsSignal.set(null);
this.statusSignal.set('unauthenticated');
this.removeStorage(TOKEN_STORAGE_KEY);
this.removeStorage(REFRESH_STORAGE_KEY);
this.clearRefreshTimer();
}
private scheduleRefresh(claims: JwtClaims): void {
this.clearRefreshTimer();
const expiresInMs = claims.exp * 1000 - Date.now();
const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000);
this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs);
}
private clearRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
}
private readStorage(key: string): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
}
private writeStorage(key: string, value: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
}
private removeStorage(key: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
}
}

View File

@@ -0,0 +1,35 @@
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2. */
export interface ServerCart {
id: string;
marketplaceId: string;
customerId?: string;
sessionToken?: string;
createdAt: string;
expiresAt: string;
}
export interface ServerCartLine {
id: string;
cartId: string;
offerId: string;
qty: number;
addedAt: string;
priceChanged?: boolean;
}
export interface DeliveryOption {
id: string;
marketplaceId: string;
label: string;
type: 'pickup' | 'courier' | 'digital';
}
export interface CheckoutSession {
id: string;
cartId: string;
customerContact: { email?: string; phone?: string; verified: boolean };
deliveryOptionId: string;
status: 'open' | 'confirmed' | 'expired';
createdAt: string;
expiresAt: string;
}

View File

@@ -0,0 +1,11 @@
import { Observable } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export interface ServerCartGateway {
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }>;
addLine(offerId: string, qty: number): Observable<ServerCartLine>;
updateLine(lineId: string, qty: number): Observable<ServerCartLine>;
removeLine(lineId: string): Observable<void>;
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { ServerCartGateway } from './server-cart-gateway.interface';
import { ServerCartLocalGateway } from './server-cart-local.gateway';
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
providedIn: 'root',
factory: () => inject(ServerCartLocalGateway),
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
import { ServerCartGateway } from './server-cart-gateway.interface';
const CART_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* In-memory stand-in for the server cart. The LIVE cart today is
* localStorage/Telegram-CloudStorage backed (pages/cart/cart.component.ts,
* services/cart.service.ts) and deliberately untouched by this module - see
* docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md for why swapping that live
* payment-adjacent flow needs its own dedicated, verified pass rather than
* a bundled mock-data rewire.
*/
@Injectable({ providedIn: 'root' })
export class ServerCartLocalGateway implements ServerCartGateway {
private cart: ServerCart = {
id: 'cart_local',
marketplaceId: 'default',
sessionToken: 'local-session',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + CART_TTL_MS).toISOString(),
};
private lines: ServerCartLine[] = [];
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
return of({ cart: this.cart, lines: this.lines });
}
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
const existing = this.lines.find(l => l.offerId === offerId);
if (existing) {
existing.qty += qty;
return of(existing);
}
const line: ServerCartLine = { id: `line_${Date.now()}`, cartId: this.cart.id, offerId, qty, addedAt: new Date().toISOString() };
this.lines.push(line);
return of(line);
}
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
const line = this.lines.find(l => l.id === lineId);
if (line) {
line.qty = qty;
}
return of(line as ServerCartLine);
}
removeLine(lineId: string): Observable<void> {
this.lines = this.lines.filter(l => l.id !== lineId);
return of(void 0);
}
startCheckout(deliveryOptionId: string, _currency: string): Observable<CheckoutSession> {
return of({
id: `chk_${Date.now()}`,
cartId: this.cart.id,
customerContact: { verified: false },
deliveryOptionId,
status: 'open',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
});
}
}

View File

@@ -0,0 +1,58 @@
/** Per docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §1. Gorbushka-class (mall_directory) tenants. */
export interface Shop {
id: string;
marketplaceId: string;
shopCategoryId: string;
name: string;
floorId?: string;
status: 'draft' | 'published';
}
export interface ShopCategory {
id: string;
marketplaceId: string;
title: string;
}
export interface MallService {
id: string;
marketplaceId: string;
title: string;
description: string;
status: 'draft' | 'published';
}
export interface Floor {
id: string;
marketplaceId: string;
order: number;
label: string;
}
export interface SchemePin {
id: string;
marketplaceId: string;
floorId: string;
shopId?: string;
x: number;
y: number;
}
export interface RentListing {
id: string;
marketplaceId: string;
title: string;
areaSqm: number;
floorId?: string;
status: 'available' | 'leased';
}
export interface Lead {
id: string;
marketplaceId: string;
rentListingId?: string;
contactName: string;
contactPhone: string;
message?: string;
createdAt: string;
}

View File

@@ -0,0 +1,12 @@
import { Observable } from 'rxjs';
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
/** Per docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md §2. */
export interface MallContentGateway {
loadShops(): Observable<Shop[]>;
loadShopCategories(): Observable<ShopCategory[]>;
loadFloors(): Observable<Floor[]>;
loadSchemePins(floorId: string): Observable<SchemePin[]>;
loadRentListings(): Observable<RentListing[]>;
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { MallContentGateway } from './mall-content-gateway.interface';
import { MallContentLocalGateway } from './mall-content-local.gateway';
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
providedIn: 'root',
factory: () => inject(MallContentLocalGateway),
});

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Floor, Lead, RentListing, SchemePin, Shop, ShopCategory } from '../models/mall-content.model';
import { MallContentGateway } from './mall-content-gateway.interface';
/**
* Lowest-priority phase per the delivery plan - only after Commerce Core is
* real. Seeded empty; the current Gorbushka frontend/archive remains UX
* reference only per ADR-0001, production data routes through the shared
* platform once docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md ships.
*/
@Injectable({ providedIn: 'root' })
export class MallContentLocalGateway implements MallContentGateway {
loadShops(): Observable<Shop[]> { return of([]); }
loadShopCategories(): Observable<ShopCategory[]> { return of([]); }
loadFloors(): Observable<Floor[]> { return of([]); }
loadSchemePins(_floorId: string): Observable<SchemePin[]> { return of([]); }
loadRentListings(): Observable<RentListing[]> { return of([]); }
submitLead(lead: Omit<Lead, 'id' | 'createdAt'>): Observable<Lead> {
return of({ ...lead, id: `lead_${Date.now()}`, createdAt: new Date().toISOString() });
}
}

View File

@@ -0,0 +1,38 @@
import { Money } from '../../pricing/models/money.model';
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
export interface Refund {
id: string;
orderId: string;
orderLineIds: string[];
amount: Money;
reason: string;
actor: string;
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
requestedAt: string;
completedAt?: string;
}
export interface ReconciliationRecord {
id: string;
orderId: string;
providerPaymentId?: string;
internalAmount: Money;
providerAmount?: Money;
matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback';
result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch';
resolvedBy?: string;
resolvedAt?: string;
}
export interface Settlement {
id: string;
sellerId: string;
periodStart: string;
periodEnd: string;
grossAmount: Money;
commission: Money;
refunds: Money;
netPayout: Money;
status: 'pending' | 'paid';
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
export interface FinanceGateway {
loadRefunds(orderId?: string): Observable<Refund[]>;
loadReconciliationQueue(): Observable<ReconciliationRecord[]>;
resolveReconciliation(id: string, note: string): Observable<void>;
loadSettlements(sellerId?: string): Observable<Settlement[]>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { FinanceGateway } from './finance-gateway.interface';
import { FinanceLocalGateway } from './finance-local.gateway';
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
providedIn: 'root',
factory: () => inject(FinanceLocalGateway),
});

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
import { FinanceGateway } from './finance-gateway.interface';
/**
* Seeded empty. AdminOrdersLocalGateway's requestRefund(id) exists as a mock
* method but nothing reads it into a real Refund/reconciliation flow yet -
* this gateway is the real target once docs/backend/
* PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md ships.
*/
@Injectable({ providedIn: 'root' })
export class FinanceLocalGateway implements FinanceGateway {
private reconciliation: ReconciliationRecord[] = [];
loadRefunds(_orderId?: string): Observable<Refund[]> {
return of([]);
}
loadReconciliationQueue(): Observable<ReconciliationRecord[]> {
return of(this.reconciliation);
}
resolveReconciliation(id: string, _note: string): Observable<void> {
this.reconciliation = this.reconciliation.filter(r => r.id !== id);
return of(void 0);
}
loadSettlements(_sellerId?: string): Observable<Settlement[]> {
return of([]);
}
}

View File

@@ -0,0 +1,28 @@
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §1. */
export interface Customer {
id: string;
marketplaceId: string;
name?: string;
email?: string;
phone?: string;
status: 'active' | 'suspended';
createdAt: string;
}
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
export interface ExternalIdentity {
customerId: string;
provider: ExternalIdentityProvider;
providerUserId: string;
verifiedAt: string;
lastUsedAt: string;
}
export interface ContactChannel {
customerId: string;
provider: 'telegram' | 'vk' | 'max';
chatId: string;
verified: boolean;
notificationsEnabled: boolean;
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { Customer } from '../models/customer-identity.model';
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. OAuth completion is backend-side; this is the client-facing surface only. */
export interface VkIdGateway {
getAuthorizeUrl(): Observable<string>;
completeCallback(code: string, codeVerifier: string): Observable<Customer>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { VkIdGateway } from './vk-id-gateway.interface';
import { VkIdLocalGateway } from './vk-id-local.gateway';
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
providedIn: 'root',
factory: () => inject(VkIdLocalGateway),
});

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Customer } from '../models/customer-identity.model';
import { VkIdGateway } from './vk-id-gateway.interface';
/**
* No real VK OAuth app is configured yet - this mock exists so the
* VkIdLoginButtonComponent has something to call and the flow shape is
* provable end-to-end before a real client id/secret exist. Swap
* VK_ID_GATEWAY once docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2
* ships; the real backend completes OAuth server-side, this interface never
* exposes a client secret regardless of implementation.
*/
@Injectable({ providedIn: 'root' })
export class VkIdLocalGateway implements VkIdGateway {
getAuthorizeUrl(): Observable<string> {
return of('about:blank#vk-id-not-configured');
}
completeCallback(_code: string, _codeVerifier: string): Observable<Customer> {
return of({
id: 'customer_vk_mock',
marketplaceId: 'default',
status: 'active',
createdAt: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,21 @@
/** Per docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §2. */
export interface Connector {
id: string;
marketplaceId: string;
provider: string;
authType: 'webhook_signed' | 'api_key' | 'oauth2';
status: 'active' | 'paused' | 'error';
lastSuccessAt?: string;
lagSeconds?: number;
errorCount: number;
backlogCount: number;
unmatchedCount: number;
}
export interface DeadLetterEntry {
id: string;
connectorId: string;
reason: string;
retryCount: number;
lastAttemptAt: string;
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { Connector, DeadLetterEntry } from '../models/connector.model';
export interface ConnectorGateway {
loadConnectors(): Observable<Connector[]>;
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]>;
replay(deadLetterId: string): Observable<void>;
pause(connectorId: string): Observable<void>;
resume(connectorId: string): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { ConnectorGateway } from './connector-gateway.interface';
import { ConnectorLocalGateway } from './connector-local.gateway';
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
providedIn: 'root',
factory: () => inject(ConnectorLocalGateway),
});

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Connector, DeadLetterEntry } from '../models/connector.model';
import { ConnectorGateway } from './connector-gateway.interface';
/**
* No connector exists in real life yet (Sprint 0.1: no fixed partner list -
* connectors onboard as partners arrive). Seeded with zero rows, ready to
* light up as soon as an admin onboards the first real connector via
* docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7.
*/
@Injectable({ providedIn: 'root' })
export class ConnectorLocalGateway implements ConnectorGateway {
private connectors: Connector[] = [];
loadConnectors(): Observable<Connector[]> {
return of(this.connectors);
}
loadDeadLetter(_connectorId: string): Observable<DeadLetterEntry[]> {
return of([]);
}
replay(_deadLetterId: string): Observable<void> {
return of(void 0);
}
pause(connectorId: string): Observable<void> {
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'paused' } : c);
return of(void 0);
}
resume(connectorId: string): Observable<void> {
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'active' } : c);
return of(void 0);
}
}

View File

@@ -0,0 +1,28 @@
/** Per docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §1-2. */
export type MarketplaceLifecycleState =
| 'draft' | 'configured' | 'content_ready' | 'domains_planned'
| 'staging_live' | 'qa_passed' | 'production_ready' | 'live' | 'paused' | 'archived';
export interface Marketplace {
id: string;
name: string;
code: string;
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
ownerId: string;
countries: string[];
currencies: string[];
lifecycleState: MarketplaceLifecycleState;
}
export interface MarketplaceDomain {
marketplaceId: string;
domain: string;
type: 'production' | 'www' | 'staging' | 'preview' | 'api' | 'seller';
status: 'planned' | 'dns_pending' | 'ssl_pending' | 'active' | 'failed';
}
export interface LifecycleAdvanceResult {
currentState: MarketplaceLifecycleState;
nextState: MarketplaceLifecycleState | null;
blockers: string[];
}

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rxjs';
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
/** Per docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §2-4. */
export interface MarketplaceGateway {
loadMarketplaces(): Observable<Marketplace[]>;
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]>;
loadLifecycle(marketplaceId: string): Observable<LifecycleAdvanceResult>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { MarketplaceGateway } from './marketplace-gateway.interface';
import { MarketplaceLocalGateway } from './marketplace-local.gateway';
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
providedIn: 'root',
factory: () => inject(MarketplaceLocalGateway),
});

View File

@@ -0,0 +1,42 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { LifecycleAdvanceResult, Marketplace, MarketplaceDomain } from '../models/marketplace.model';
import { MarketplaceGateway } from './marketplace-gateway.interface';
import { ConfigService } from '../../config/config.service';
/**
* The platform runs one live tenant per deployment today - there is no
* registry of multiple marketplaces anywhere in the codebase. This derives
* a single-row "registry" from the current tenant's own bootstrap so the
* shape is real, ready to become a genuine multi-row registry once
* docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md ships.
*/
@Injectable({ providedIn: 'root' })
export class MarketplaceLocalGateway implements MarketplaceGateway {
private readonly configService = inject(ConfigService);
loadMarketplaces(): Observable<Marketplace[]> {
const bootstrap = this.configService.getBootstrapSnapshot();
return of([{
id: 'current',
name: bootstrap?.branding?.brandName || 'This marketplace',
code: 'current',
type: 'commerce',
ownerId: 'unknown',
countries: [],
currencies: [],
lifecycleState: 'live',
}]);
}
loadDomains(marketplaceId: string): Observable<MarketplaceDomain[]> {
if (typeof window === 'undefined') {
return of([]);
}
return of([{ marketplaceId, domain: window.location.hostname, type: 'production', status: 'active' }]);
}
loadLifecycle(_marketplaceId: string): Observable<LifecycleAdvanceResult> {
return of({ currentState: 'live', nextState: null, blockers: [] });
}
}

View File

@@ -0,0 +1,30 @@
import { Money } from '../../pricing/models/money.model';
/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §2-3. */
export interface Offer {
id: string;
marketplaceId: string;
sellerId: string;
variantId: string;
sellerSku: string;
price: Money;
stockPolicy: 'track' | 'no_track' | 'preorder';
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
publishedAt?: string;
executabilityChecked: boolean;
}
export interface InventoryRecord {
offerId: string;
available: number;
reserved: number;
sold: number;
warehouse?: string;
source: 'manual' | 'feed_sync' | 'connector';
}
export interface OfferLookupQuery {
sku?: string;
sellerSku?: string;
externalId?: string;
}

View File

@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model';
/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export interface OfferGateway {
loadOffers(sellerId?: string): Observable<Offer[]>;
lookup(query: OfferLookupQuery): Observable<Offer[]>;
loadInventory(offerId: string): Observable<InventoryRecord | null>;
publish(offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { OfferGateway } from './offer-gateway.interface';
import { OfferLocalGateway } from './offer-local.gateway';
/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
export const OFFER_GATEWAY = new InjectionToken<OfferGateway>('OFFER_GATEWAY', {
providedIn: 'root',
factory: () => inject(OfferLocalGateway),
});

View File

@@ -0,0 +1,58 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model';
import { OfferGateway } from './offer-gateway.interface';
import { ADMIN_PRODUCTS_GATEWAY } from '../../../features/admin/products/services/admin-products-gateway.token';
import { fromMajor } from '../../pricing/models/money.model';
/**
* Derives Offers from the existing (mock) Products gateway - one offer per
* product, sellerId defaulted to 'marketplace-owned' when absent, exactly
* matching AdminProduct.sellerId's existing "absent means marketplace-owned"
* convention. Real Offer/Product split (docs/backend/
* PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) replaces this once a real
* backend exists - swap OFFER_GATEWAY, no caller changes needed.
*/
@Injectable({ providedIn: 'root' })
export class OfferLocalGateway implements OfferGateway {
private readonly productsGateway = inject(ADMIN_PRODUCTS_GATEWAY);
loadOffers(sellerId?: string): Observable<Offer[]> {
return this.productsGateway.loadProducts({ search: '', page: 1, pageSize: 100 } as any).pipe(
map((result: any) => (result.items ?? [])
.map((p: any): Offer => this.toOffer(p))
.filter((o: Offer) => !sellerId || o.sellerId === sellerId))
);
}
lookup(query: OfferLookupQuery): Observable<Offer[]> {
return this.loadOffers().pipe(
map(offers => offers.filter(o =>
(!query.sku || o.sellerSku === query.sku) &&
(!query.sellerSku || o.sellerSku === query.sellerSku)
))
);
}
loadInventory(offerId: string): Observable<InventoryRecord | null> {
return of({ offerId, available: 42, reserved: 0, sold: 0, source: 'manual' });
}
publish(_offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }> {
return of({ ok: true });
}
private toOffer(product: any): Offer {
return {
id: `offer_${product.id}`,
marketplaceId: 'default',
sellerId: product.sellerId ?? 'marketplace-owned',
variantId: product.id,
sellerSku: product.sku,
price: fromMajor(product.price ?? 0, product.currency ?? 'RUB'),
stockPolicy: 'track',
status: product.visible ? 'published' : 'draft',
executabilityChecked: !!product.visible,
};
}
}

View File

@@ -0,0 +1,20 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { map } from 'rxjs/operators';
import { PERMISSION_GATEWAY } from '../services/permission-gateway.token';
/**
* Route guard shape for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2.
* Against PermissionLocalGateway (grants everything - see that file's doc
* comment) this is currently a no-op, by design: it must not create a false
* sense of enforcement before a real backend exists. Attach via route data
* (`data: { requiredScope: '...' }`) once real enforcement is needed;
* wiring this onto the 14 existing live admin routes is deliberately not
* done in this pass - that needs its own verified rollout, not a blanket
* retrofit that could lock an admin out without warning.
*/
export function requiresScope(scope: string): CanActivateFn {
return () => inject(PERMISSION_GATEWAY).getSessionPermissions().pipe(
map(permissions => permissions.scopes.includes('*') || permissions.scopes.includes(scope))
);
}

View File

@@ -0,0 +1,19 @@
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §1-2. */
export type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
export type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
export interface SessionPermissions {
role: PlatformRole | MarketplaceRole;
scopes: string[];
marketplaceIds: string[];
}
export interface AuditEvent {
id: string;
actor: string;
action: string;
entityType: string;
entityId: string;
reason?: string;
occurredAt: string;
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { AuditEvent, SessionPermissions } from '../models/permission.model';
/** Per docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
export interface PermissionGateway {
getSessionPermissions(): Observable<SessionPermissions>;
loadAuditLog(): Observable<AuditEvent[]>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { PermissionGateway } from './permission-gateway.interface';
import { PermissionLocalGateway } from './permission-local.gateway';
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2. */
export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', {
providedIn: 'root',
factory: () => inject(PermissionLocalGateway),
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AuditEvent, SessionPermissions } from '../models/permission.model';
import { PermissionGateway } from './permission-gateway.interface';
/**
* Grants full PLATFORM_OWNER access unconditionally. This matches today's
* REAL behaviour (GAPS-AND-IMPROVEMENTS.md: "the admin role model is
* decorative - anyone who passes admin authentication has full access
* regardless of assigned role") rather than pretending enforcement exists
* when it doesn't. Swapping PERMISSION_GATEWAY for a real backend per
* docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md is what actually turns
* enforcement on - PermissionGuard below is inert against this mock by
* design, not a false sense of security.
*/
@Injectable({ providedIn: 'root' })
export class PermissionLocalGateway implements PermissionGateway {
getSessionPermissions(): Observable<SessionPermissions> {
return of({ role: 'PLATFORM_OWNER', scopes: ['*'], marketplaceIds: ['*'] });
}
loadAuditLog(): Observable<AuditEvent[]> {
return of([]);
}
}

View File

@@ -0,0 +1,14 @@
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3. */
export interface FxQuote {
quoteId: string;
base: string;
quote: string;
rate: number;
source: string;
observedAt: string;
expiresAt: string;
}
export function isFxQuoteExpired(fxQuote: FxQuote, now: Date = new Date()): boolean {
return now.getTime() >= new Date(fxQuote.expiresAt).getTime();
}

View File

@@ -0,0 +1,47 @@
/** Minor-unit money, per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §2. No float for money math. */
export interface Money {
amountMinor: number;
currency: string;
}
const MINOR_UNIT_DECIMALS: Record<string, number> = {
RUB: 2,
USD: 2,
EUR: 2,
AMD: 2,
};
export function decimalsFor(currency: string): number {
return MINOR_UNIT_DECIMALS[currency] ?? 2;
}
export function toMajor(money: Money): number {
return money.amountMinor / Math.pow(10, decimalsFor(money.currency));
}
export function fromMajor(amount: number, currency: string): Money {
const decimals = decimalsFor(currency);
return { amountMinor: Math.round(amount * Math.pow(10, decimals)), currency };
}
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Cannot add Money of different currencies: ${a.currency} vs ${b.currency}`);
}
return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency };
}
export function subtractMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Cannot subtract Money of different currencies: ${a.currency} vs ${b.currency}`);
}
return { amountMinor: a.amountMinor - b.amountMinor, currency: a.currency };
}
export function multiplyMoney(money: Money, factor: number): Money {
return { amountMinor: Math.round(money.amountMinor * factor), currency: money.currency };
}
export function zeroMoney(currency: string): Money {
return { amountMinor: 0, currency };
}

View File

@@ -0,0 +1,30 @@
import { Money } from './money.model';
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §4. Immutable once created. */
export interface PriceSnapshot {
id: string;
offerId: string;
amount: Money;
displayAmount: Money;
fxQuoteId: string | null;
capturedAt: string;
}
export interface CheckoutLine {
offerId: string;
qty: number;
unitPrice: Money;
lineTotal: Money;
priceSnapshotId: string;
}
export interface CheckoutResult {
checkoutSessionId: string;
lines: CheckoutLine[];
subtotal: Money;
discount: Money;
delivery: Money;
total: Money;
fxQuoteId: string | null;
expiresAt: string;
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { FxQuote } from '../models/fx-quote.model';
/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */
export interface FxQuoteGateway {
getQuote(base: string, quote: string): Observable<FxQuote>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { FxQuoteGateway } from './fx-quote-gateway.interface';
import { FxQuoteLocalGateway } from './fx-quote-local.gateway';
/** Swap point for the real FX backend from docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */
export const FX_QUOTE_GATEWAY = new InjectionToken<FxQuoteGateway>('FX_QUOTE_GATEWAY', {
providedIn: 'root',
factory: () => inject(FxQuoteLocalGateway),
});

View File

@@ -0,0 +1,32 @@
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { FxQuote } from '../models/fx-quote.model';
import { FxQuoteGateway } from './fx-quote-gateway.interface';
import { CurrencyRatesService } from '../../../services/currency-rates.service';
const QUOTE_TTL_MS = 5 * 60 * 1000;
/**
* Mock FX source until a real backend rate service exists (Sprint 0.1:
* FX is computed in-house, "internal" is the normal source value, not just
* a fallback). Derives a quote from CurrencyRatesService's existing
* admin-editable rates so the shape is real even though the source isn't.
*/
@Injectable({ providedIn: 'root' })
export class FxQuoteLocalGateway implements FxQuoteGateway {
private readonly currencyRates = inject(CurrencyRatesService);
getQuote(base: string, quote: string): Observable<FxQuote> {
const rate = this.currencyRates.convert(1, base, quote);
const now = new Date();
return of({
quoteId: `fxq_local_${base}_${quote}_${now.getTime()}`,
base,
quote,
rate,
source: 'local-mock',
observedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + QUOTE_TTL_MS).toISOString(),
});
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs'; import { Observable, map } from 'rxjs';
import { ApiService } from '../../../services'; import { ApiService } from '../../../services';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '@marketplaces/auth';
import { CategoryService } from '../../categories/category.service'; import { CategoryService } from '../../categories/category.service';
import { ProductDataProvider } from './product-data-provider.interface'; import { ProductDataProvider } from './product-data-provider.interface';
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model'; import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';

View File

@@ -0,0 +1,12 @@
import { UUID } from '../../../shared/types/primitive.types';
/** Per docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §2, §4. */
export type SellerRole = 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER' | 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
export interface SellerUser {
id: UUID;
sellerId: UUID;
role: SellerRole;
email: string;
status: 'active' | 'invited' | 'suspended';
}

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rxjs';
import { Seller } from '../models/seller.model';
import { SellerUser } from '../models/seller-user.model';
/** Per docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. Operates on the existing Seller domain type. */
export interface SellerGateway {
loadSellers(): Observable<Seller[]>;
loadTeam(sellerId: string): Observable<SellerUser[]>;
}

Some files were not shown because too many files have changed in this diff Show More