Merge improvements/fork-harvest into main
Fork-harvest brings: the ip-api.com geo fix, credential bundle scan, mock gateways out of production, JIT compiler dropped (1.55->1.04 MB), host hardening, provider-agnostic identity + VK/Yandex + account linking, and the backend contracts consolidated into one BACKEND-INTEGRATION.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/backend/BACKEND-HANDOFF.md # docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
# Admin credential authentication handoff
|
||||
|
||||
## Current production state
|
||||
|
||||
`admin.gorbushka.market` can authenticate through the existing Telegram
|
||||
session flow. Login/password authentication is not implemented by the live
|
||||
backend, so the frontend must not validate or embed administrator credentials.
|
||||
|
||||
The existing `/admin-login` Ed25519 page is also not production-ready because
|
||||
the backend challenge/verify endpoints do not exist.
|
||||
|
||||
## Required backend API
|
||||
|
||||
Tenant identity comes only from nginx's trusted `X-Storefront-Host` header.
|
||||
Never accept a tenant or marketplace identifier from the login request body.
|
||||
|
||||
### Create session
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"login": "gorbushka",
|
||||
"password": "<secret>"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "<short-lived JWT>",
|
||||
"refreshToken": "<rotating opaque token>",
|
||||
"expiresAt": "2026-08-21T04:00:00Z",
|
||||
"mustChangePassword": true,
|
||||
"user": {
|
||||
"id": "<id>",
|
||||
"login": "gorbushka",
|
||||
"displayName": "Gorbushka administrator",
|
||||
"roles": ["MARKETPLACE_ADMIN"],
|
||||
"tenantId": "<tenant-id>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
|
||||
- `400` malformed request.
|
||||
- `401 INVALID_CREDENTIALS` with one generic message for unknown login and
|
||||
wrong password.
|
||||
- `403 TENANT_DISABLED` or `TENANT_MISMATCH`.
|
||||
- `429 RATE_LIMITED` with `Retry-After`.
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session/refresh
|
||||
DELETE /api/identity/v1/session
|
||||
POST /api/identity/v1/session/change-password
|
||||
GET /api/identity/v1/session/permissions
|
||||
```
|
||||
|
||||
`change-password` accepts `{ currentPassword, newPassword }`. While
|
||||
`mustChangePassword` is true, every non-auth admin endpoint returns
|
||||
`403 PASSWORD_CHANGE_REQUIRED`.
|
||||
|
||||
## Provisioning and security requirements
|
||||
|
||||
- Generate a random one-time bootstrap password. Do not use the documented
|
||||
deterministic `{slug}2026$` pattern in production.
|
||||
- Store only an Argon2id password hash with a unique salt.
|
||||
- Never log passwords, refresh tokens, authorization headers, or session IDs.
|
||||
- Rate-limit by tenant, login, and source IP; add exponential backoff.
|
||||
- Rotate refresh tokens and revoke the full token family on reuse.
|
||||
- Enforce tenant and role authorization on every admin endpoint. Angular
|
||||
guards are UI only.
|
||||
- Audit login success/failure, password change, refresh-token reuse, logout,
|
||||
and lockout without recording secrets.
|
||||
|
||||
## Required nginx invariants
|
||||
|
||||
Backend nginx changes must preserve:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Storefront-Host $storefront_host;
|
||||
proxy_set_header Origin "";
|
||||
|
||||
add_header Access-Control-Allow-Headers \
|
||||
"Authorization, Content-Type, AdminWebSessionID, WebSessionID, Currency, X-Language, X-Region, X-Requested-With" always;
|
||||
```
|
||||
|
||||
For `Origin: https://admin.gorbushka.market`, `$storefront_host` must be
|
||||
`gorbushka.market`. The API upstream remains `https://127.0.0.1:445` unless
|
||||
the backend team deliberately changes the listening address.
|
||||
|
||||
## Frontend follow-up after backend delivery
|
||||
|
||||
Add the credential form to the admin-only login shell, submit only over HTTPS,
|
||||
store the returned admin session separately from customer auth, force the
|
||||
password-change screen when requested, and keep Telegram as an optional
|
||||
fallback. Do not expose a non-functional credential form before the API ships.
|
||||
@@ -1,107 +0,0 @@
|
||||
# Backend handoff — start here
|
||||
|
||||
Single entry point for a backend developer picking this up cold. Written 2026-08-18.
|
||||
|
||||
## 1. What this is
|
||||
|
||||
`marketplaces` is a multi-tenant marketplace platform frontend (Angular 22). The frontend is built and waiting; **there is no backend yet**. Every wire contract the backend needs to implement is already written and sitting in this directory — see [README.md](README.md) for the full index and build order.
|
||||
|
||||
## 1a. Multi-tenancy — the thing that shapes every endpoint
|
||||
|
||||
The final executable infrastructure/backend contract is
|
||||
[TENANT-API-DOMAIN-HANDOFF.md](TENANT-API-DOMAIN-HANDOFF.md). Follow it for
|
||||
hostname normalization, CORS, nginx, TLS, CI secrets, and acceptance checks.
|
||||
|
||||
One deployed bundle serves **every customer domain**. There is no per-tenant build. The chain is:
|
||||
|
||||
1. [`TenantResolverService`](../../src/app/core/config/tenant-resolver.service.ts) reads the complete current browser hostname and protocol (localhost still uses the development proxy).
|
||||
2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) uses one API host per base domain: both `example.com` and `store1.example.com` use `api.example.com`.
|
||||
3. `ApiBootstrapProvider`, auth, legacy API calls, and versioned `/api/...` calls all use that same base.
|
||||
4. Each base domain owns one API DNS/TLS/reverse-proxy entry. nginx validates the browser origin and forwards the complete storefront hostname as `X-Storefront-Host`.
|
||||
5. Backend tenant lookup uses that trusted storefront hostname, not the shared API `Host`; frontend nginx remains `default_server` / `server_name _`, so any attached storefront domain receives the same bundle.
|
||||
|
||||
**What this means for you:** the bootstrap endpoint is the single most important thing to build after auth. Every request must be tenant-scoped server-side, and a tenant must never be able to read another tenant's data — return `403`, not an empty result (see [TRACK-S §2](TRACK-S-SECURITY-RBAC-CONTRACT.md)). The frontend supplies the tenant identity from the hostname; the backend must treat that as an untrusted hint and derive real scope from the authenticated session.
|
||||
|
||||
Constraints already fixed by the frontend design (see the platform-vision facts in `docs/context/`): no marketplace-specific code or hardcoded marketplace data in the frontend; bootstrap carries only what is needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages) and **never** products, orders, cart, or users.
|
||||
|
||||
[PHASE-9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) covers the marketplace registry, domain attachment, and publish/revision model.
|
||||
|
||||
## 2. Read in this order
|
||||
|
||||
1. [README.md](README.md) — index of all contracts, build order, and what's deliberately excluded.
|
||||
2. [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — start here. Everything after depends on the money model.
|
||||
3. Phases 2→4 — the rest of the launch gate (orders, catalog, connectors).
|
||||
4. [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) — **gates the launch.** Today the admin role model is decorative: nothing server-side enforces any permission. §8 covers per-marketplace bootstrap admin accounts and self-service sub-admin management.
|
||||
5. [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) — longest lead time, start it in parallel with Phase 1.
|
||||
6. Phases 5→10 — post-launch-gate.
|
||||
7. [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) — the inbound partner API. Read it **before implementing Phase 1**, not after: it adds `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and two levels above `Marketplace` ([Phase 9 §1](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). Building the partner API itself can wait; carrying its routing dimension in the payments tables cannot.
|
||||
|
||||
[../../BACKEND-API-REFERENCE.md](../../BACKEND-API-REFERENCE.md) documents the *current* live API surface (legacy endpoints, error envelope, mock-only areas). New endpoints use `/api/v2/...` namespaces; legacy endpoints are not being migrated.
|
||||
|
||||
## 3. Auth — read before writing any endpoint
|
||||
|
||||
Auth is no longer part of this repo. It lives in `@marketplaces/auth`, published from [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git). See [../PACKAGES-USAGE.md](../PACKAGES-USAGE.md) for the full client surface. What matters on the backend side:
|
||||
|
||||
**Two mechanisms exist client-side.**
|
||||
|
||||
- **Telegram QR/session (live).** Endpoints under `{authApiUrl}/users/sessions` — `POST` to create, `GET /{id}` to poll, `DELETE /{id}` to log out. Both customer and admin login call the *same* endpoints; only client-side storage differs. The response shape is normalized permissively client-side (many key spellings accepted), but a clean implementation should return `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
|
||||
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify`, `POST /api/admin/auth/refresh`, `POST /api/admin/auth/logout`. Contracts in [TRACK-S](TRACK-S-SECURITY-RBAC-CONTRACT.md) and the package's `ed25519/models/auth-api.model.ts`. Until these ship, the client shows a `backend-unavailable` screen — nothing is mocked.
|
||||
|
||||
**The critical gap:** the session API has no concept of "admin." The frontend cannot distinguish an admin session from a customer one — it only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side.** Client-side guards are UI convenience, never security. This is the single most serious open issue in the system.
|
||||
|
||||
Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
## 4. Environment / infrastructure state
|
||||
|
||||
Dev server `213.21.246.138` (user `seto`, sudo, SSH key provided separately).
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| nginx 1.24 | **Installed, running.** Config at `/etc/nginx/sites-enabled/marketplaces-dev.conf`. Serves frontend from `/srv/marketplaces/current/frontend`, backoffice from `/srv/marketplaces/current/backoffice`, proxies `/api/` → `127.0.0.1:8080`. `/health` returns `ok`. |
|
||||
| Go toolchain | Installed (`/usr/local/bin/go`). |
|
||||
| Backend service on :8080 | **Not running.** Nothing is listening. `/srv/marketplaces/current/api` is an empty shell. nginx's `/api/` proxy currently 502s. |
|
||||
| PostgreSQL | **Installed but inactive.** Needs starting, a database, a user, and schema before anything works. |
|
||||
| Shared packages | `@marketplaces/auth` installs over plain git from a release branch — no registry, token, or tunnel needed. `npm install` works out of the box. |
|
||||
| Verdaccio (npm registry) | Running in Docker on port 4873, but **superseded and unused** — nothing depends on it. See [../PACKAGE-EXTRACTION.md](../PACKAGE-EXTRACTION.md) §5. |
|
||||
| Firewall (ufw) | Active. 80/tcp, 443/tcp, OpenSSH. |
|
||||
| TLS / certbot | **Not installed.** No certificates. Everything is plain HTTP today. For multi-tenant this is real work: every customer domain needs a certificate (per-domain issuance, or a wildcard if all tenants sit under one apex). |
|
||||
| DNS / dynamic subdomains | **Not set up.** No domain currently points at the server (reverse DNS is the provider default `silky-bronze.ptr.network`). No wildcard record, no per-tenant subdomain automation, no Hostinger DNS integration. The *application* is fully multi-tenant (§1a) — this is the missing infrastructure underneath it. [PHASE-9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) specifies the target. |
|
||||
| Frontend deploy (CD) | **None.** Pushing to `main` deploys nothing. `architecture-governance.yml` builds and checks boundaries but has no deploy step, and nothing writes to `/srv/marketplaces/current/frontend`. Deploys are manual today. |
|
||||
| CI runner | None on this server; `sources.vitanova.network` CI runs elsewhere. |
|
||||
|
||||
## 5. To get a working dev environment
|
||||
|
||||
Nothing here is done yet — this is the setup a backend dev does on day one.
|
||||
|
||||
1. Start and configure PostgreSQL; create the database and application user.
|
||||
2. Design the schema from the Phase 1–4 contracts (schema design is explicitly the backend's own call — the contracts specify entities, endpoints, and invariants, never tables). Tenant scoping belongs in the schema from day one; retrofitting it is painful.
|
||||
3. Build the API service, listen on `127.0.0.1:8080`. nginx already proxies `/api/` to it.
|
||||
4. Implement the **bootstrap config endpoint** (§1a) — without it the frontend cannot render for any tenant.
|
||||
5. Implement the Telegram session endpoints — the login flow is fully built client-side and blocked only on these.
|
||||
6. Implement `GET /api/identity/v1/session/permissions` ([TRACK-S §2](TRACK-S-SECURITY-RBAC-CONTRACT.md)) — frontend route guards derive from it.
|
||||
7. Seed per-marketplace bootstrap admins ([TRACK-S §8](TRACK-S-SECURITY-RBAC-CONTRACT.md)): login = marketplace slug, cryptographically random one-time password delivered out of band, `mustChangePassword: true`.
|
||||
|
||||
Steps 4–6 unblock the entire frontend. Everything after is feature work.
|
||||
|
||||
## 6. Frontend deploy
|
||||
|
||||
```bash
|
||||
git clone <marketplaces repo>
|
||||
npm install # pulls @marketplaces/auth over git, no credentials needed
|
||||
npm run build # -> dist/dexarmarket
|
||||
```
|
||||
|
||||
Angular 22, Node 24+. nginx serves `/srv/marketplaces/current/frontend`.
|
||||
[`deploy.yml`](../../.github/workflows/deploy.yml) builds and atomically deploys
|
||||
pushes to `main`; one deployment updates every domain at once. Before activation,
|
||||
the workflow reconciles TLS, exact CORS, and reverse proxying for every host in
|
||||
`STOREFRONT_DOMAINS`. Production deployment requires the documented CI secrets
|
||||
and the one-time [`server-setup.sh`](../../scripts/deploy/server-setup.sh) run.
|
||||
|
||||
## 7. Known open decisions
|
||||
|
||||
- Registry reachability for CI (reverse proxy + TLS, or a different registry entirely).
|
||||
- ~~Backend ownership.~~ Answered 2026-08-18: implemented by a separate backend developer against this contract set.
|
||||
- Additional payment providers (wallets, BNPL) — [Phase 7 §4](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md).
|
||||
- Per-connector marketplace adapters — written per partner at onboarding, [Phase 4 §8](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md).
|
||||
- Backfill of `Company`/`Project`/`PaymentPoint` for existing marketplaces — sequence specified in [Phase 9 §1.2](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md), not yet scheduled.
|
||||
496
docs/backend/BACKEND-INTEGRATION.md
Normal file
496
docs/backend/BACKEND-INTEGRATION.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Backend — the whole thing, one file
|
||||
|
||||
**Date:** 2026-08-22 · **Branch of record:** `improvements/fork-harvest`
|
||||
|
||||
This is the single source of truth for the marketplaces backend. It replaces the former `docs/backend/` set (Phase 1–10, Track A/S, the handoffs, the partner and harvest docs) — all of it is folded in here. The frontend is Angular 22, built and waiting; **there is no backend yet.** Everything below is the wire contract and the invariants the frontend needs, never DB schema or service boundaries, which stay the backend's own call.
|
||||
|
||||
> **The rule (keep this file alive).** When a backend need is added, a contract changes, or something ships, update THIS file in the same change — the relevant section and the change log at the bottom (§14). One file, always current. Do not create a new backend `.md`; add a section here.
|
||||
|
||||
---
|
||||
|
||||
## 0. Map
|
||||
|
||||
| § | Area | Was |
|
||||
|---|---|---|
|
||||
| 1 | System shape — multi-tenancy, auth, infra state | Handoff §1–4 |
|
||||
| 2 | Release invariants (the gate) | Handoff §0 |
|
||||
| 3 | How work lands — PR & release discipline | Handoff §0a |
|
||||
| 4 | Cross-cutting mechanisms | Phase 1/Track S/Partner |
|
||||
| 5 | Money, FX, payment state machine | Phase 1 |
|
||||
| 6 | Cart & checkout | Phase 6 |
|
||||
| 7 | Payments, reconciliation, refunds, settlements | Phase 7 |
|
||||
| 8 | Catalog, offers, inventory, fulfillment | Phase 3 |
|
||||
| 9 | Orders, events, notifications | Phase 2 |
|
||||
| 10 | Identity & messaging (VK/Yandex/Telegram/MAX) | Phase 8 |
|
||||
| 11 | Tenant registry, domains, publish | Phase 9 |
|
||||
| 12 | Sellers · connectors · content · analytics · partner API | Phase 5/4/10, Track A, Partner |
|
||||
| 13 | Infra, tenant routing, deploy | Tenant-API handoff + hardening |
|
||||
| — | Acceptance tests · build order · dev setup · open decisions · change log | §2 end, §15–18, §14 |
|
||||
|
||||
New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND-API-REFERENCE.md`) are not being migrated.
|
||||
|
||||
---
|
||||
|
||||
## 1. System shape
|
||||
|
||||
### 1.1 Multi-tenancy — shapes every endpoint
|
||||
|
||||
One deployed bundle serves **every** customer domain; there is no per-tenant build. The chain: `TenantResolverService` reads the browser hostname → `ApiConfigService` uses one API host per base domain (`example.com` and `store1.example.com` both use `api.example.com`) → nginx validates the browser origin and forwards the full storefront hostname as `X-Storefront-Host` → the backend resolves the tenant from that trusted header, **never** from the shared API `Host`, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return `403`, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users.
|
||||
|
||||
### 1.2 Auth — read before writing any endpoint
|
||||
|
||||
Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PACKAGES-USAGE.md`). Two mechanisms exist client-side:
|
||||
|
||||
- **Telegram QR/session (live).** `{authApiUrl}/users/sessions` — `POST` create, `GET /{id}` poll, `DELETE /{id}` logout. A clean implementation returns `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
|
||||
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify|refresh|logout`.
|
||||
|
||||
**The critical gap:** the session API has no concept of "admin." The frontend only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side** — client-side guards are UI convenience, never security. Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
|
||||
|
||||
**Admin credential (login/password) auth — required, not yet built.** `admin.gorbushka.market` can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 `/admin-login` page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted `X-Storefront-Host` — never from the login body.
|
||||
|
||||
```http
|
||||
POST /api/identity/v1/session { login, password }
|
||||
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
|
||||
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
|
||||
POST /api/identity/v1/session/refresh
|
||||
DELETE /api/identity/v1/session
|
||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
||||
GET /api/identity/v1/session/permissions
|
||||
```
|
||||
|
||||
Errors: `400` malformed; `401 INVALID_CREDENTIALS` (one generic message for unknown login and wrong password); `403 TENANT_DISABLED` / `TENANT_MISMATCH`; `429 RATE_LIMITED` with `Retry-After`. While `mustChangePassword` is true, every non-auth admin endpoint returns `403 PASSWORD_CHANGE_REQUIRED`. Provisioning: random one-time bootstrap password (never `{slug}2026$`), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve `proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin "";` and, for `Origin: https://admin.gorbushka.market`, resolve `$storefront_host` to `gorbushka.market`; API upstream stays `https://127.0.0.1:445`.
|
||||
|
||||
### 1.3 Infrastructure state (dev server `213.21.246.138`, user `seto`)
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| nginx 1.24 | Running. Proxies `/api/` → `127.0.0.1:8080`; `/health` = `ok`. |
|
||||
| Backend on :8080 | **Not running.** `/api/` currently 502s. |
|
||||
| PostgreSQL | Installed, inactive. Needs db, user, schema. |
|
||||
| `@marketplaces/auth` | Installs over plain git, no credentials. |
|
||||
| TLS / certbot | **Not installed.** Plain HTTP today. Multi-tenant needs per-domain or wildcard certs. |
|
||||
| DNS / subdomains | **Not set up.** No domain points at the server. Phase-equivalent target in §11. |
|
||||
| Frontend CD | Push to `main` deploys nothing today; `deploy.yml` exists (§13). |
|
||||
|
||||
Host hardening (sshd, fail2ban, sysctl) **is** applied on the frontend deploy — see `../DEPLOYMENT.md` §3.2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Release invariants (the gate)
|
||||
|
||||
A release that violates any one of these does not ship. Each is falsifiable; the acceptance tests are in §15.
|
||||
|
||||
1. Public tenant is determined by verified `Host` alone. No public endpoint accepts a `marketplaceId` from the browser.
|
||||
2. The price of an order is computed by the backend. A price in a request is ignored, never validated-and-used.
|
||||
3. Stock and reservation change atomically — two buyers racing for the last unit produce exactly one payable order.
|
||||
4. Payment creation and webhook receipt are idempotent, enforced by unique constraints, not handler logic.
|
||||
5. Provider credentials never leave the backend — not in a response, not in a bundle, not in a log.
|
||||
6. A published revision is immutable. Rollback creates a new revision; history is never rewritten.
|
||||
7. Rolling back design does not roll back live inventory, orders, or payments.
|
||||
8. No user reads a marketplace they are not assigned to — through the UI or a direct API call.
|
||||
9. Every administrative mutation leaves an audit record: actor, action, before, after.
|
||||
|
||||
---
|
||||
|
||||
## 3. How work lands
|
||||
|
||||
**One functional area per PR.** Each carries: purpose, screenshots (where UI), API changes, migrations, test evidence, security impact, rollback plan. Never change a payment/inventory/order state machine in the same PR as a redesign.
|
||||
|
||||
**Migrations are expand/contract.** The expand step must be deployable on its own.
|
||||
|
||||
**A release is not "the build passed."** Each records version, migrations applied, healthcheck, post-deploy smoke, dependency audit, and the rollback path. Audit coverage (invariant 9) is a property every mutating endpoint carries from its first line, not a step.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-cutting mechanisms
|
||||
|
||||
### 4.1 Money
|
||||
|
||||
All money is minor units, never float. `Money { amountMinor: number; currency: string }` (ISO 4217). RUB/USD/EUR/AMD are 2-decimal. Conversion rounds half-up to the currency's minor-unit precision, once, at the point of conversion — never re-rounded on redisplay.
|
||||
|
||||
### 4.2 Sessions (FH-2.3)
|
||||
|
||||
- Token = 32 random bytes, stored as **SHA-256 hash only** — a DB read yields no usable credential.
|
||||
- `HttpOnly; Secure; SameSite`; revocable; rows carry `expiresAt`/`revokedAt`/`ip`/`userAgent`. Admin sessions 12 h, customer sessions 30 days.
|
||||
- **One cookie name per contour** — `bo_session` / `manager_session` / `marketplace_session`. A customer session must never satisfy an admin guard; the guarantee is different cookies checked by different guards.
|
||||
- Validation rejects on: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not enrolled.
|
||||
- Password change revokes every live session for the user **in the same transaction** as the password write.
|
||||
- Credentials: Argon2id `memoryCost 65536, timeCost 3, parallelism 1`, ≥16 chars. TOTP **mandatory** for every platform/marketplace role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token + `otpauth://` URI and issues no session until confirmed. The enrolment token grants nothing else.
|
||||
|
||||
### 4.3 Origin allowlist (FH-2.4)
|
||||
|
||||
One hook ahead of routing: any non-`GET`/`HEAD`/`OPTIONS` on `/api/admin/*`, `/api/platform/*`, `/api/manager/*` whose `Origin` is not allowlisted → `403`, before the handler. CORS uses the same allowlist with `credentials:true` — never `*`, never reflected. The allowlist is per-environment configuration.
|
||||
|
||||
### 4.4 Encrypted secret envelope (FH-2.9)
|
||||
|
||||
Stored credentials use `v1.<iv>.<authTag>.<ciphertext>` base64url, AES-256-GCM, 12-byte random IV per value, 32-byte key from env/secret-manager. The version tag lets the algorithm rotate. Decrypt only inside the using service — never on a DTO, in a log, or in any response (including to a `PLATFORM_OWNER`; backoffice shows presence, last-rotated, and an HMAC fingerprint, not the value). Fingerprints are `HMAC-SHA256(key, value)`. Redirect/callback URLs are built backend-side from the verified domain and allowlisted; the browser receives a URL to navigate to, never the material to build one. Covers payment credentials, connector credentials, bot tokens, FX keys, per-tenant OAuth secrets.
|
||||
|
||||
### 4.5 RoutingContext (Partner §7, on every payment)
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf
|
||||
leafNodeId: string; // the payment point money is accepted at
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string; // partner-supplied, opaque, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
Required on `CheckoutSession`, `PaymentIntent`, `Payment`, and every refund/reconciliation/settlement row. Resolved and **frozen at checkout-session creation**, immutable for the payment's life. `routingPath` must resolve to exactly one leaf or the payment is rejected at creation (never accepted and resolved during reconciliation). A payment whose leaf is `suspended`/`disabled` is rejected. `environment` must match the credential's or `403`. **Carry it from the first payment row — retrofitting it onto a populated table is far more expensive.**
|
||||
|
||||
### 4.6 RBAC (Track S)
|
||||
|
||||
17 roles, 3 scopes:
|
||||
|
||||
```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';
|
||||
```
|
||||
|
||||
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint checks `(role, tenantScope)` against the session **before** touching data. A `MARKETPLACE_ADMIN` for A querying B's data gets `403`, not an empty result. `GET /api/identity/v1/session/permissions -> { role, scopes[], marketplaceIds[] }` is what frontend guards derive from — never hardcode role logic client-side beyond hiding affordances.
|
||||
|
||||
**Step-up auth** required before: bank/payment detail changes, production launch, role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, any manual financial override. **PII minimization:** exposed only to roles that need it for scope; export endpoints are themselves audited.
|
||||
|
||||
**Bootstrap admin & self-service** (§8 of old Track S): each marketplace ships one bootstrap `MARKETPLACE_ADMIN` — `login` = marketplace slug, `password` = a cryptographically random one-time secret delivered out of band (never derived from the slug), `mustChangePassword: true`; login succeeds but every non-auth request `403`s with `PASSWORD_CHANGE_REQUIRED` until changed. `POST /api/identity/v1/session/change-password`. A `MARKETPLACE_ADMIN` provisions sub-admins scoped to its own tenant via `POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }` (+ `GET/PATCH/DELETE /team`); `role` must be a `MarketplaceRole` (platform-scope → `403 SCOPE_ESCALATION_DENIED`), `marketplaceId` is forced server-side to the caller's scope, every change audited, `MARKETPLACE_ADMIN` grants require step-up.
|
||||
|
||||
### 4.7 Audit log
|
||||
|
||||
```ts
|
||||
interface AuditEvent {
|
||||
id: string; actor: string; action: string; // 'role.changed', 'offer.price_updated', 'refund.approved'
|
||||
entityType: string; entityId: string;
|
||||
before?: unknown; after?: unknown; reason?: string; occurredAt: string; ip?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Mandatory coverage: permission changes, seller status changes, catalog moderation, price changes, payment/refund actions, manual order overrides, credential changes, launch actions. `GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=`.
|
||||
|
||||
### 4.8 Rate limiting
|
||||
|
||||
`429 { error: { code: 'RATE_LIMITED', retryAfterSeconds } }` on storefront/auth/provider endpoints. Partner limits are per `partnerId` by tier, published in the OpenAPI so a partner reads its limit rather than discovering it via `429`.
|
||||
|
||||
### 4.9 Order-manager contour (FH-2.14)
|
||||
|
||||
`ORDER_MANAGER` is a **separate surface**, not a narrower menu: own URL, shell, login, and session cookie; a manager hitting a backoffice URL gets `403` from the guard. Scope from **membership rows, never configuration**. Catalog, design, domains, payment settings, platform users refuse — not merely hidden. PII masked in lists, revealed in detail only with permission, reveal and export audited.
|
||||
|
||||
---
|
||||
|
||||
## 5. Money, FX, payment state machine (Phase 1)
|
||||
|
||||
**Why:** rates are typed into `localStorage` and drift; the charged `amount` is computed client-side and trusted; nothing records which FX rate produced a price. Bank/NSPK totals can't reconcile.
|
||||
|
||||
**FX quote.** `GET /api/v2/pricing/fx-quote?base=RUB"e=USD` → `{ quoteId, base, quote, rate, source, observedAt, expiresAt }`. `rate` may be float (market rate, not money). The frontend must re-fetch past `expiresAt`. If the source is down, the backend either blocks (`503 FX_SOURCE_UNAVAILABLE`) or serves a `"source":"fallback"` quote — a tenant setting. FX source is **ours, in-house, as the default** (`source: "internal"`); no external provider committed.
|
||||
|
||||
**PriceSnapshot.** Created once at checkout, immutable. `{ id, offerId, amount, displayAmount, fxQuoteId, capturedAt }`. Never recalculated — an old order shows the price it was actually charged.
|
||||
|
||||
**Server-authoritative amount (highest priority).** Replace client-trusted `POST /cart {amount, items[{price}]}` with:
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/checkout { offers: [{offerId, qty}], currency, deliveryOptionId }
|
||||
```
|
||||
|
||||
The frontend sends offer ids + quantities only; the backend computes every price from the live offer price and current FX quote. **No `amount`/`price` is ever accepted from the client for anything affecting the charge.** `POST /api/v2/storefront/payments/intents` references `checkoutSessionId` only. Total = `sum(unitPrice*qty) − discounts + delivery + taxes/fees`, reconstructable per line for backoffice.
|
||||
|
||||
**Payment state machine.**
|
||||
```
|
||||
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
|
||||
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
|
||||
Order: pending_payment -> paid -> processing -> fulfilled/completed
|
||||
```
|
||||
`PaymentEvent { id, paymentIntentId, fromState, toState, providerEventId, providerTimestamp, receivedAt, processedAt }`. No fixed delays anywhere. Webhook: `POST /api/providers/v1/payments/{provider}/webhook` — signature mandatory (`401` on fail), idempotency key `provider + providerEventId`, on success emit `payment.confirmed`/`payment.failed` onto the bus so order creation is event-driven. Idempotent order creation: `POST /api/admin/v2/orders` (internal) with `Idempotency-Key: <checkoutSessionId>` returns the existing order on retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cart & checkout (Phase 6)
|
||||
|
||||
Server-owned cart from add-to-cart onward (today it's `localStorage` + Telegram CloudStorage; `features/website/checkout/` is empty).
|
||||
|
||||
```ts
|
||||
interface Cart { id; marketplaceId; customerId?; sessionToken?; createdAt; expiresAt }
|
||||
interface CartLine { id; cartId; offerId; qty; addedAt } // never a client price
|
||||
interface CheckoutSession { id; cartId; customerContact:{email?,phone?,verified}; deliveryOptionId; status:'open'|'confirmed'|'expired'; createdAt; expiresAt }
|
||||
interface DeliveryOption { id; marketplaceId; label; price: Money; type:'pickup'|'courier'|'digital' }
|
||||
```
|
||||
|
||||
```
|
||||
POST /api/v2/storefront/cart/lines { offerId, qty }
|
||||
PATCH /api/v2/storefront/cart/lines/{id} { qty }
|
||||
DELETE /api/v2/storefront/cart/lines/{id}
|
||||
GET /api/v2/storefront/cart
|
||||
```
|
||||
|
||||
Idempotent mutations; qty validated against Offer/Inventory on **every** mutation. Guest cart by `sessionToken`, merges into the customer cart on login (never drops items). Inactive carts and their reservations clear on `expiresAt`. **Price-refresh:** `GET /cart` returns captured price + current price + `priceChanged` when an offer's price moved; the frontend must confirm before checkout, the backend must expose the comparison, never silently pick one. Checkout reads the server cart directly; contact requirement and guest-checkout allowance are per-tenant policy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payments, reconciliation, refunds, settlements (Phase 7)
|
||||
|
||||
**Idempotency as constraints (FH-2.2).** `UNIQUE(payment.idempotency_key)` and `UNIQUE(payment_webhook_event.provider, event_key)`.
|
||||
- Payment create requires `Idempotency-Key`; same key + same order → return existing, + different order/marketplace → `409`.
|
||||
- Webhook: insert the event row **first**; a unique-violation is the duplicate signal → `{accepted:true, duplicate:true}`, stop. Only a successful insert applies the status change; set `processedAt` after applying (a crash between insert and apply shows as unprocessed, not lost). `event_key` = provider event id, else `sha256(rawBody)`. **Signature verified against the raw body** before any parse.
|
||||
- Poll as reconciliation, not primary: a scheduled job re-checks provider status for payments still `pending` in the last 24 h and applies through the same state-machine path; transient failures swallowed, next tick retries. No fixed delay, no UI-driven poll standing in for a missed webhook.
|
||||
|
||||
**Refunds.** `Refund { id, orderId, orderLineIds[], amount, reason, actor, status:'requested'|'approved'|'processing'|'completed'|'failed', requestedAt, completedAt?, routing }`. Routing is **copied verbatim** from the original payment, never re-resolved — a store suspended after payment is still refundable. `POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }`, `GET` same. Updates `Payment.status` to `refunded`/`partially_refunded`, emits `refund.requested`/`refund.completed`.
|
||||
|
||||
**Reconciliation.** `ReconciliationRecord { id, orderId, providerPaymentId?, internalAmount, providerAmount?, matchStrategy:'provider_payment_id'|'merchant_reference'|'amount_currency_fallback', result:'matched'|'unmatched'|'duplicate'|'amount_mismatch'|'status_mismatch', resolvedBy?, resolvedAt?, resolutionNote?, routing }`. Match by providerPaymentId → merchant reference → amount+currency; surface non-matched in backoffice with audited resolution. `GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=`, `POST /{id}/resolve {note}`.
|
||||
|
||||
**Settlements.** `Settlement { id, sellerId, periodStart, periodEnd, grossAmount, commission, refunds, netPayout, status:'pending'|'paid' }`. Seller split happens **after** routing: payment → routed to one payment point (frozen at checkout) → reconciled there → split across the sellers whose lines the order contains. A settlement belongs to one seller within one store; a seller in two stores gets two settlements. Splitting never rewrites RoutingContext. `grossAmount` across a store's settlements must reconcile against that store's matched rows for the period. `GET /api/seller/v1/finance/settlements`, `GET /api/admin/v2/finance/settlements?...`.
|
||||
|
||||
**Provider breadth:** QR + card today via one integration; the `PaymentIntent`/`Payment` shapes are provider-agnostic, so wallets/BNPL are a new adapter behind the same state machine — an open business decision, no action until made.
|
||||
|
||||
---
|
||||
|
||||
## 8. Catalog, offers, inventory, fulfillment (Phase 3)
|
||||
|
||||
**Two-layer split.** `Product` (content) vs `Offer` (one seller's proposition). One product, many offers.
|
||||
|
||||
```ts
|
||||
interface Product { id; marketplaceId; categoryId; brand?; title; description; attributes; media[]; status:'draft'|'moderation'|'published'|'paused'|'archived' }
|
||||
interface Variant { id; productId; sku; barcode?; optionValues; dimensions? }
|
||||
interface Category { id; marketplaceId; parentId|null; slug; attributesSchema; order; seo }
|
||||
interface Offer { id; marketplaceId; sellerId; variantId; sellerSku; price: Money; stockPolicy:'track'|'no_track'|'preorder'; status:...; publishedAt?; executabilityChecked }
|
||||
interface PriceHistory { offerId; price: Money; changedBy; changedAt }
|
||||
```
|
||||
|
||||
**Inventory** `{ offerId, available, reserved, sold, warehouse?, source }` + `StockReservation { id, offerId, qty, reason:'checkout'|'pre_payment', expiresAt, released }`. available/reserved/sold counted separately, never derived. Feed updates are idempotent upserts. Oversell → dedicated incident queue, never silently hidden.
|
||||
|
||||
**Atomic reservation (FH-2.1).** Reserve with one conditional write:
|
||||
```sql
|
||||
UPDATE inventory SET reserved = reserved + :qty
|
||||
WHERE offer_id = :id AND (available - reserved) >= :qty RETURNING id
|
||||
```
|
||||
Zero rows → `409`, no retry, no partial reserve; a multi-line cart reserves every line in one transaction and rolls all back if any line returns zero. No `SELECT` before the `UPDATE`, no advisory lock — the `WHERE` clause is the concurrency control. TTL 15 min. Release and consume follow the same one-statement rule.
|
||||
|
||||
**Inventory journal (FH-2.8).** Every change writes one immutable `InventoryMovement { id, offerId, deltaAvailable, deltaReserved, deltaSold, reason, referenceType?, referenceId?, actor?, resultingAvailable, occurredAt }`. Never updated/deleted; a correction is a new compensating row. `resultingAvailable` recorded at the time; replaying the journal reproduces the record exactly. A manual adjustment without `actor` is rejected.
|
||||
|
||||
**Publish-time executability.** An offer that can't be fulfilled must not publish: valid `Fulfillment` type, stock policy `track` with `available>0` or `no_track`/`preorder`, required category attributes present. This is what makes "no branch distinguishes a buyer from an inspector" true.
|
||||
|
||||
**Digital code pools (FH-2.11).** `FulfillmentMode: manual | code_pool`. `DigitalCode { id, marketplaceId, offerId, encryptedValue, valueHash, status:'available'|'reserved'|'assigned'|'revoked', orderLineId?, createdAt, assignedAt? }`. `valueHash` unique per `(marketplace, offer)` — importing a code twice is refused by the DB. `available` for a code_pool offer derives from the count of available codes. Moves `available→reserved` under the FH-2.1 write, `reserved→assigned` only on confirmed payment. **A code is returned to the browser only when the order is `paid`/`processing`/`fulfilled`** — earlier states return an empty code list. Revocation is terminal and audited.
|
||||
|
||||
**Bulk import.** `POST /api/admin/v2/products/bulk-import` (CSV multipart or JSON array) returns a validation-error **preview**; a separate `POST .../bulk-import/{importId}/apply` commits. Idempotent by SKU/external key (FH-2.15) — re-run updates, never duplicates; a row-level error never publishes a partial result; rollback-able only while none of its products have appeared on a paid order, then archive.
|
||||
|
||||
**Endpoints.** `GET/POST/PATCH /api/admin/v2/products[/{id}]`, `GET/POST/PATCH /api/admin/v2/offers[/{id}]`, `POST /api/admin/v2/offers/{id}/publish` (runs executability, `422 details[]` on fail), `GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Orders, events, notifications (Phase 2)
|
||||
|
||||
**One `Order` per checkout**, regardless of seller count; lines group into per-seller `Fulfillment`. No parent/child splitting. A seller sees only their `Fulfillment` group and their `OrderLine`s.
|
||||
|
||||
```ts
|
||||
interface Order { id; marketplaceId; source:'storefront'|'external'|'backoffice'|'api_partner'; externalOrderRef?; customerId?; currency; subtotal; discount; delivery; total: Money; paymentStatus; orderStatus; createdAt; paidAt? }
|
||||
interface OrderLine { id; orderId; offerId; sellerId; skuSnapshot; titleSnapshot; qty; unitPrice; lineTotal: Money; priceSnapshotId }
|
||||
interface Fulfillment { id; orderId; sellerId; type:'manual'|'warehouse'|'pickup'|'digital'; status:'pending'|'assigned'|'in_progress'|'issued'|'shipped'|'cancelled'; assignedTo?; issuedAt?; shippedAt?; evidence? }
|
||||
interface OrderEvent { id; orderId; type:'created'|'paid'|'seller_notified'|'accepted'|'fulfilled'|'cancelled'|'refunded'; actor?; occurredAt; metadata? }
|
||||
interface OrderContactSnapshot { orderId; name; email?; phone?; preferredChannel?; capturedAt } // immutable
|
||||
```
|
||||
|
||||
**Public token + snapshot completeness (FH-2.13).** `Order.publicToken` ≥24 random bytes, base64url, unique; **every customer-facing route addresses an order by it, never by `id`** (a sequential id turns "check my order" into enumeration). `GET /api/v2/storefront/orders/{publicToken}` is tenant-scoped; a valid token from another marketplace → `404`. `OrderLine` snapshots everything that must survive a later edit — currency, per-line discount, delivery option and price, tax/fee components — written once at creation, never updated in place; a correction is a new event/refund/amendment.
|
||||
|
||||
**Endpoints.** `GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=`, `GET /{id}`, `PATCH /{id}/status`, `POST /{id}/refund-request`, `POST /{id}/notes`, `POST /{id}/archive|restore`, `DELETE /{id}`. `GET /api/seller/v1/orders` returns only the authenticated seller's fulfillment groups and lines.
|
||||
|
||||
**Event bus.** `order.created|paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested|completed`, `external_order.imported`. Backend owns the implementation. Contract: `order.paid` **always** produces a backoffice notification even if every external channel is down.
|
||||
|
||||
**Notification Center.** `Notification { id, marketplaceId, entityType, entityId, severity:'info'|'warning'|'critical', eventType, read, deepLink, createdAt }` + `DeliveryAttempt { notificationId, channel, status:'sent'|'failed', error?, attemptedAt }`. `GET /api/admin/v2/notifications?...`, `PATCH /{id}/read`. A `DeliveryAttempt` failure never prevents the `Notification` row from being created and visible.
|
||||
|
||||
---
|
||||
|
||||
## 10. Identity & messaging (Phase 8)
|
||||
|
||||
Customer identity providers: VK ID and Yandex ID (OAuth), Telegram and MAX (bot/QR). **Frontend is built and tested** — provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen all exist; what's left is backend + the FH-0.1 decision.
|
||||
|
||||
**Provider-agnostic surface (FH-4.1/4.2).**
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||
```
|
||||
`/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` **single-use for 10 min**, returns/302s to the provider with `code_challenge` (S256). `/callback` validates `state`, exchanges the code with the stored verifier, links the identity, issues the session cookie, redirects to a `returnTo` validated against the tenant origin. **The client never sees a secret, token, or verifier** — we are a confidential client, the backend owns PKCE. Unknown/expired/replayed `state` → generic error.
|
||||
|
||||
**`ExternalIdentity` (FH-4.3).** `{ customerId, provider:'vk_id'|'yandex_id'|'telegram'|'max', providerUserId, email?, phone?, displayName?, verifiedAt, lastUsedAt }`. `UNIQUE(provider, providerUserId)`; a provider account already bound to a *different* customer is an identity conflict routed to controlled resolution — never a silent rebind, enforced by the index. Email optional (VK often returns none). Per-tenant OAuth app config `{ clientId, clientSecret, scopes[], redirectUri }` stored under the §4.4 envelope.
|
||||
|
||||
**VK ID (FH-4.4).** OAuth 2.1, PKCE mandatory. Authorize `id.vk.com/authorize`, token `POST id.vk.com/oauth2/auth`, profile `POST id.vk.com/oauth2/user_info`, logout on unlink. **The callback returns `device_id` alongside `code` and the token exchange fails without it** — the most common integration bug.
|
||||
|
||||
**Yandex ID (FH-4.5).** OAuth 2.0 + PKCE. Authorize `oauth.yandex.ru/authorize`, token `POST oauth.yandex.ru/token` (HTTP Basic `client_id:client_secret`), profile `GET login.yandex.ru/info?format=json` (`Authorization: OAuth <token>`). A second strategy on the same surface; build after VK.
|
||||
|
||||
**Telegram → identity (FH-4.6).** A Telegram login writes an `ExternalIdentity` (`provider:'telegram'`) under the same uniqueness/conflict rule; appears in `/me/identities`, unlinkable subject to the **last-identity `409`** (never remove a customer's only login). Keep customer (`marketplace_session`) and admin (`bo_session`) sessions as distinct cookies — closes the shared customer/admin session finding. The identity row and the messaging `BotConversationBinding` stay separate records.
|
||||
|
||||
**Email/phone OTP (FH-4.8).** Recovery when a linked messenger is unreachable and an addable second factor — never the primary login; one more identity/contact on the same customer, not a parallel account. Implements the existing `../superpowers/specs/2026-08-15-email-phone-login-design.md`.
|
||||
|
||||
**MAX + Telegram bot channels.** `BotConversationBinding { customerId, marketplaceId, provider:'telegram'|'max', chatId, state, orderId?, lastMessageAt }`. MAX linking: `POST /api/identity/v1/max/link-code -> { code, expiresAt }` (single-use, bound to marketplace + browser session); user sends the code to the bot; `POST /api/providers/v1/max/bot-webhook` (idempotent) links the session. All providers' bot updates normalize to `MessagingEvent { provider, chatId, orderId?, text?, receivedAt }`. Bot tokens never reach the frontend.
|
||||
|
||||
**Notification Orchestrator + delivery conversation.** `order.paid` routes to the customer's chosen channel; the backoffice notification always fires even if the messenger is down. The bot never changes financial statuses — it writes delivery-detail fields via a dedicated service only. Follow-ups rate-limited, then hand off to a human. `POST /api/providers/v1/{provider}/bot-webhook`, `GET /api/admin/v2/orders/{orderId}/conversation`, `POST /{orderId}/conversation/handoff`.
|
||||
|
||||
**Blocking decision — FH-0.1.** VK and Yandex validate `redirect_uri` against an exact registered list; a multi-tenant platform can't register one per tenant domain. Resolution to confirm: one **central identity host** as the sole registered callback, tenant carried in the signed `state`, a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for the session cookie. Also decide: one VK account across two storefronts — one `Customer` or two? (`Customer.marketplaceId` implies two, the safer default.) Record both in an ADR before any identity code.
|
||||
|
||||
---
|
||||
|
||||
## 11. Tenant registry, domains, publish (Phase 9)
|
||||
|
||||
**Hierarchy** (Company → Project → Marketplace → PaymentPoint; see §12 partner API). `Company`/`Project` are thin ownership/scope nodes; all config stays on `Marketplace`.
|
||||
|
||||
```ts
|
||||
interface Marketplace { id; companyId; projectId; externalReference?; name; code; type:'commerce'|'mall_directory'|'hybrid'|'single_brand'; ownerId; countries[]; locales[]; currencies[]; timezone; lifecycleState }
|
||||
type MarketplaceLifecycleState = 'draft'|'configured'|'content_ready'|'domains_planned'|'staging_live'|'qa_passed'|'production_ready'|'live'|'paused'|'archived';
|
||||
interface MarketplaceDomain { marketplaceId; domain; type:'production'|'www'|'staging'|'preview'|'api'|'seller'; status:'planned'|'dns_pending'|'ssl_pending'|'active'|'failed' }
|
||||
interface MarketplaceFeatureSet { marketplaceId; features: Record<string,boolean> }
|
||||
interface MarketplaceRevision { id; marketplaceId; status:'draft'|'validated'|'preview'|'published'; publishedAt?; supersedesRevisionId? }
|
||||
interface PaymentPoint { id; marketplaceId; method:'qr'|'card'; currencies[]; externalReference?; status; providerAccountRef?; createdAt; updatedAt }
|
||||
```
|
||||
|
||||
Creating a payment point registers the channel but does **not** enable real money (needs `providerAccountRef` via a separate flow). Backfill existing marketplaces: create a Company, a Project ("marketplaces"), set `companyId`/`projectId` on every marketplace, create PaymentPoints for existing methods, then make the fks non-nullable.
|
||||
|
||||
**Lifecycle.** `GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers[] }` (return the *specific* blocker), `POST .../lifecycle/advance`. **Onboarding wizard** — 8 steps: `POST /marketplaces` (name/code/type/owner/locales/currencies/timezone), `PATCH /{id}/feature-set`, `POST /{id}/domains`, `PATCH /{id}/design`, `POST /{id}/roles`, `PATCH /{id}/integrations`, `POST /{id}/staging-launch` (smoke tests), `POST /{id}/production-launch` (all P0 blockers closed + approval).
|
||||
|
||||
**Domain automation (Hostinger).** `GET/POST(validate)/PUT/DELETE /api/dns/v1/zones/{domain}`, `GET /snapshots/{domain}[/{id}]`, `POST /snapshots/{domain}/{id}/restore`. Order: read zone → **snapshot before any change** → build+validate plan → never touch MX/SPF/DKIM/DMARC/CAA without a scoped task → apply after approval → verify propagation/SSL/health → mark `active` only then.
|
||||
|
||||
**Publish model.** `draft → validation → preview → publish`. `POST /api/admin/v2/marketplaces/{id}/revisions`, `.../{revId}/validate|publish|rollback`.
|
||||
- **Immutability (FH-2.7):** `version = max(version)+1`, `UNIQUE(marketplaceId, version)`, materialized snapshot (a product renamed tomorrow doesn't change what was published today), `publishedRevision` pointer flipped in the publishing transaction, rollback writes revision *n* as *max+1* (history only grows). **Operational state — inventory, reservations, orders, payments — never travels with a revision.**
|
||||
- **Clone (FH-2.7):** carries theme/sections/pages/navigation/category tree/collections/offer assignments; **never** carries domains/admin users/customers/sessions/orders/payments/credentials/webhook secrets/audit. Inventory starts at zero unless a platform role opts otherwise. Category walk is topological with cycle detection (`400` naming the cycle).
|
||||
- **Preview (FH-2.6):** `POST .../{id}/preview-token -> { url, expiresAt }`. HMAC over `{marketplaceId, expiresAt, nonce}`, 15-min TTL, `storefront_preview` HttpOnly cookie, constant-time compare, invalid/expired → `404` (an unpublished storefront doesn't confirm its existence). **While the preview cookie is present, every non-`GET` on the public API → `404`** (hook ahead of routing). Responses carry `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
**Tenant resolution (FH-2.5).** `GET /api/v2/storefront/bootstrap` resolves server-side from verified `Host`. Normalize: lowercase, strip trailing dot, strip port, then match a unique `hostname` row — resolve only once `verifiedAt` is set and the marketplace serves. Brief cache (~30 s) with **explicit invalidation** on domain add/verify/remove and state change. `Host` read from the trusted proxy chain (proxy overwrites the client value). **No public endpoint accepts `marketplaceId`.** Unknown/unverified host → `404`, no fallback tenant.
|
||||
|
||||
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every ledger row are not part of a revision.
|
||||
|
||||
---
|
||||
|
||||
## 12. Sellers · connectors · content · analytics · partner API
|
||||
|
||||
### 12.1 Seller portal (Phase 5)
|
||||
|
||||
A seller never owns a separate `Order` — they see their `Fulfillment` groups and `OrderLine`s within shared orders, pre-filtered server-side (never trust a frontend `sellerId`).
|
||||
|
||||
```ts
|
||||
interface SellerOrganization { id; marketplaceId; legalName; status:'pending'|'approved'|'suspended'|'rejected'; bankDetailsRef; createdAt }
|
||||
interface SellerUser { id; sellerOrganizationId; role: SellerRole; email; status:'active'|'invited'|'suspended' }
|
||||
interface SellerMarketplaceMembership { sellerOrganizationId; marketplaceId; status }
|
||||
interface SellerIntegration { sellerOrganizationId; apiCredentialRef; webhookUrl?; lastSyncAt?; lastSyncError? }
|
||||
```
|
||||
|
||||
`POST /api/seller/v1/onboarding`, `GET /profile`, `GET/POST/PATCH /offers`, `POST /offers/bulk-price-update`, `GET /orders`, `PATCH /orders/{orderId}/fulfillment/{fulfillmentId}`, `GET /finance/accruals|settlements`, `POST /finance/bank-details` (step-up + audit, optional maker/checker), `GET /team`, `POST /team/invite`, `GET /integrations`. Every endpoint enforces `SellerUser.role` server-side; the query layer carries an implicit `WHERE sellerOrganizationId = :authenticatedSeller` — a seller can never reach another seller's data by parameter manipulation.
|
||||
|
||||
### 12.2 Connectors — external order ingest (Phase 4)
|
||||
|
||||
A new partner connector is an onboarding action, not a code change. Fixed shared pipeline: ingest → verify/auth → persist `RawExternalEvent` **before parsing** → normalize to a canonical shape → map `externalSku → Offer` (no mapping → Unmatched queue, never silent) → create/update order (`source:'external'`) → emit events → push status back if supported.
|
||||
|
||||
```ts
|
||||
interface Connector { id; marketplaceId; provider; authType:'webhook_signed'|'api_key'|'oauth2'; credentialRef; pollingIntervalSeconds?; cursorState?; status:'active'|'paused'|'error' }
|
||||
interface RawExternalEvent { id; connectorId; payload; receivedAt; processedAt? }
|
||||
interface ExternalOrderMapping { connectorId; externalSellerId; externalProductId; externalSku; internalSellerId; internalOfferId }
|
||||
interface DeadLetter { id; connectorId; rawEventId; reason; retryCount; lastAttemptAt; resolvedAt? }
|
||||
interface ExternalOrderEvent { connectorId; externalOrderId; externalCreatedAt; customer; lines[{externalSku,qty,unitPriceMinor,currency}]; totalMinor; currency; rawEventId }
|
||||
```
|
||||
|
||||
Idempotency key = `connectorId + externalOrderId/eventId`; **zero duplicate orders on repeated delivery**. `POST /api/providers/v1/{connector}/webhook`, `GET/POST/PATCH /api/admin/v2/integrations[/{id}]`, `GET /{id}/unmatched`, `POST /{id}/unmatched/{eventId}/resolve`, `POST /{id}/dead-letter/{id}/replay`. SLA: webhook 99% under 60 s; polling delay ≤ `interval + 60`; every error carries a trace id.
|
||||
|
||||
### 12.3 Content modules — mall-class tenants (Phase 10)
|
||||
|
||||
Lowest priority, only after commerce core is real. Entities (all carry `marketplaceId`, audit, and the §11 draft/publish flow): `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `Lead`, `NewsPromo`, `MallSettings`. `GET/POST/PATCH/DELETE /api/admin/v2/content/{shops|shop-categories|services|floors|scheme-pins|rent-listings|news}`, `POST /content/rent-listings/{id}/leads`, `PATCH /content/mall-settings`. Commerce modules are platform-ready but off via `MarketplaceFeatureSet` — the point is proving a tenant flips `catalog`/`cart`/`checkout` to `true` later with zero code change.
|
||||
|
||||
**Server-side content validation (FH-2.10).** The server re-runs the editor's rules on write. Clamp-and-fallback: clamp out-of-range numbers, fall back an invalid colour, blank a URL that isn't a same-origin path or `https://`, trim/truncate text. Structural violations (unknown block type, malformed id, too many blocks/ids) → `400`. Limits published as one schema both sides read. Referential checks (block → deleted category / unpublished offer) are publish blockers unless a fallback is declared.
|
||||
|
||||
### 12.4 Analytics (Track A) — start early, longest lead time
|
||||
|
||||
`AnalyticsEvent { eventType, marketplaceId, sessionId, customerId?, timestamp, properties, isSynthetic }`. `POST /api/v2/storefront/analytics/events`. Backend is source of truth for `sessionId` and `isSynthetic` — **never trust a client synthetic flag.** Vocabulary: traffic (`session_started`, `page_view`, `product_view`), catalog (`search`, `category_view`, `seller_view`), commerce (`add_to_cart`, `checkout_started`, `payment_started|success|failed`, `order_created`) — emitted from the same code paths that produce `PaymentEvent`/`OrderEvent`, not a drifting parallel layer. `OperationalMetric` for latencies/lag. **Synthetic traffic** is staging/demo only, `isSynthetic:true` set server-side by environment/token — reports filter it by construction. `GET /api/admin/v2/analytics/funnel|operational|quality`, `GET /api/v2/storefront/search/trending`.
|
||||
|
||||
### 12.5 Partner provisioning — inbound (`/api/partner/v1/`)
|
||||
|
||||
Partners provision their own merchant hierarchy, then payments route back to the correct leaf. **Deliberately generic** — no partner name in any entity/field/endpoint; partner-specific behaviour lives in a `PartnerProfile` config row.
|
||||
|
||||
Four fixed levels `Company → Project → Store(=Marketplace) → PaymentPoint`; middle levels optional per profile. `ProvisioningNode { id, level, parentId, companyId, path[], environment:'TEST'|'LIVE', status:'active'|'suspended'|'disabled', externalReference, displayName, ... }`. `path` is server-computed; nodes never re-parent (move = disable + create); `disable` cascades terminally, `suspend` cascades reversibly by cascade id; creating a node never enables money. `TEST`/`LIVE` are a hard partition (cross-env → `403`).
|
||||
|
||||
Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/payment-points`, `PATCH /nodes/{id}/status`, `POST /nodes/{id}/disable`. Read: `GET /nodes/{id}`, `/companies/{id}/hierarchy`, `/nodes/lookup?externalReference=`, `/companies/{id}/audit`. Every `POST` needs `Idempotency-Key` (scope `(partnerId, endpoint, key)`, 24 h, same key+body → replay, +different body → `409`, no partial hierarchy). **Signed requests** (ed25519/rsa-pss), private key never transmitted, ±5 min skew, nonce replay rejected; authority is the credential's `scopeNodeId` subtree, a credential can never widen its own scope. `POST/GET/rotate/DELETE /credentials`. Stable error codes (`validation_failed 422`, `scope_forbidden 403`, `environment_mismatch 403`, `node_disabled 409`, `signature_invalid 401`, …). Partner-facing serialization uses the partner's own field names via `PartnerProfile.routingFieldNames`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Infra, tenant routing, deploy
|
||||
|
||||
**Deterministic hostname rule.** One API hostname per base domain: `example.com`, `store1.example.com`, `www.example.com` all use `https://api.example.com`. Localhost is the only exception (local `/api` proxy).
|
||||
|
||||
**Backend must,** for every request on the shared `api.<base-domain>`: use `X-Storefront-Host` (nginx derives it from a validated browser `Origin`, sends it as upstream `Host`, keeps the shared API host in `X-Forwarded-Host`); not infer a subdomain tenant from the API `Host`; resolve the normalized storefront hostname through the domain registry; reject unknown/disabled/unverified domains with `403` before reading tenant data (never fall back to a default tenant); bind the session to the resolved tenant and reject a mismatch; trust `X-Storefront-Host`/`X-Forwarded-*` only from the known proxy; return JSON for `/bootstrap` with a tenant identity matching the domain (HTML or a default-tenant response is a fault).
|
||||
|
||||
**CORS.** Echo the exact validated storefront origin, `Access-Control-Allow-Credentials: true`, `Vary: Origin`, methods `GET,POST,PUT,PATCH,DELETE,OPTIONS`, headers `Authorization, Content-Type, AdminWebSessionID, X-Requested-With`, preflight `204`. Never `*` with credentials.
|
||||
|
||||
**nginx/TLS.** `scripts/deploy/configure-api-domain.sh --domain … --email … --upstream https://127.0.0.1:445` (idempotent, root) creates the shared `api.<domain>`, issues/renews its cert, configures CORS, proxies all paths. Subdomains need no extra API DNS/cert.
|
||||
|
||||
**CI/CD.** `deploy.yml` runs the same configurator before activating a frontend release. Secrets: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, `STOREFRONT_DOMAINS`, `CERTBOT_EMAIL`, `BACKEND_UPSTREAM`. One-time `server-setup.sh` installs the root-owned configurator and host hardening (`../DEPLOYMENT.md` §3.2).
|
||||
|
||||
**Structural DB isolation (FH-D.2).** Data network `internal: true`, API bound to loopback, `no-new-privileges` on every service. **Restore drill (FH-D.1):** WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) plus a scheduled restore-check that restores into a clean environment and records the result.
|
||||
|
||||
**Acceptance:** `curl -fsS https://api.example.com/bootstrap | jq -e 'type=="object"'` and an OPTIONS preflight both pass; the bundle contains no fixed marketplace API hostname; unknown domains `403`; API never returns the Angular `index.html` fallback.
|
||||
|
||||
---
|
||||
|
||||
## 14. Change log
|
||||
|
||||
Append here whenever a section changes. Newest first.
|
||||
|
||||
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed.
|
||||
- **2026-08-21** — Harvest additions (`FH-*`) folded in across §4–§13, from the parallel-platform review ([ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)): atomic reservation, inventory journal, idempotency constraints, session model, origin allowlist, secret envelope, order public token, revision immutability/clone/preview, tenant resolution hardening, server-side content validation, digital code pools, order-manager contour, provider-agnostic identity + VK/Yandex + Telegram migration, host hardening.
|
||||
- **2026-08-18** — RoutingContext + Company/Project/PaymentPoint hierarchy added (partner provisioning); backend ownership answered (separate developer).
|
||||
- **2026-08-17** — Payment chain freeze lifted (Sprint 0.1); FX source decided in-house.
|
||||
|
||||
---
|
||||
|
||||
## 15. Acceptance tests
|
||||
|
||||
Backend integration tests — the frontend can't prove a race or a replay against a mock.
|
||||
|
||||
| # | Scenario | Passes when | Guards |
|
||||
|---|---|---|---|
|
||||
| A1 | Two concurrent checkouts for the last unit | One payable order, one clean `409` | Inv. 3 |
|
||||
| A2 | Same provider webhook delivered twice | Order completes once, stock moves once, one notification | Inv. 4 |
|
||||
| A3 | A price sent in a checkout request | Ignored; charged amount is the server's | Inv. 2 |
|
||||
| A4 | Unknown/unverified `Host` | `404`, no other tenant's data | Inv. 1 |
|
||||
| A5 | `MARKETPLACE_ADMIN` for A queries B directly | `403`, not empty | Inv. 8 |
|
||||
| A6 | Cross-origin POST with a valid session cookie | Refused | §4.3 |
|
||||
| A7 | Any credential value searched for in responses/logs/bundle | Absent | Inv. 5 |
|
||||
| A8 | Rollback a design revision | Revision restored, live inventory untouched | Inv. 6–7 |
|
||||
| A9 | Mutation while a preview cookie is present | `404` | §11 |
|
||||
| A10 | Hand-crafted config the editor would reject | Refused | §12.3 |
|
||||
| A11 | Unpaid order requests its digital code | Empty code list | §8 |
|
||||
| A12 | Re-run the same import file | Updates, no duplicate | §8 |
|
||||
| A13 | Second VK login, same `providerUserId` | Same `Customer`, no duplicate | §10 |
|
||||
| A14 | VK account already bound to another customer | Conflict resolution, no silent rebind | §10 |
|
||||
| A15 | Unlink a customer's only identity | `409` | §10 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Build order
|
||||
|
||||
1. **Launch gate (P0):** money model (§5) → orders/events (§9) → catalog/offers/inventory (§8) → connectors (§12.2). Track S (§4.6, §4.9) gates the launch — enforce it, nothing does today. Track A (§12.4) starts in parallel with §5 (longest lead time). Read the partner API (§12.5) before implementing §5 — it adds RoutingContext to the payment tables.
|
||||
2. **Publish & content:** §11 (preview/revision/clone/tenant hardening), §12.3 content validation.
|
||||
3. **Identity:** unblock FH-0.1, then §10 in order VK → Yandex → Telegram migration → OTP. Frontend already built.
|
||||
4. **Digital goods & manager contour:** §8 code pools, §4.9.
|
||||
5. **Continuous:** §13 ops.
|
||||
|
||||
---
|
||||
|
||||
## 17. Dev setup (day one)
|
||||
|
||||
1. Start/configure PostgreSQL; create db + user.
|
||||
2. Design the schema from these contracts (schema is the backend's own call; tenant scoping from day one).
|
||||
3. Build the API service on `127.0.0.1:8080` — nginx already proxies `/api/`.
|
||||
4. Implement the **bootstrap config endpoint** (§1.1) — without it the frontend can't render.
|
||||
5. Implement the Telegram session endpoints — login is fully built client-side, blocked only on these.
|
||||
6. Implement `GET /api/identity/v1/session/permissions` (§4.6) — frontend guards derive from it.
|
||||
7. Seed per-marketplace bootstrap admins (§4.6).
|
||||
|
||||
Steps 4–6 unblock the entire frontend.
|
||||
|
||||
---
|
||||
|
||||
## 18. Open decisions
|
||||
|
||||
- **FH-0.1** — central identity host + one-VK-account-across-storefronts (§10). Blocks identity.
|
||||
- Additional payment providers (wallets/BNPL) — new adapter, business decision (§7).
|
||||
- Per-connector adapters — written per partner at onboarding (§12.2).
|
||||
- Backfill of Company/Project/PaymentPoint for existing marketplaces — sequence in §11, not scheduled.
|
||||
- CI registry reachability (reverse proxy + TLS, or a different registry).
|
||||
@@ -1,393 +0,0 @@
|
||||
# Complete Frontend API Surface — Master Endpoint List
|
||||
|
||||
Generated 2026-08-18, updated same day after the frontend backlog (F1–F65) closed, updated again same day to add §0 after a direct question ("everything is there? payment auth?") caught that the first pass only grepped `src/app/` — auth moved into the external `@marketplaces/auth` package this session, and its own HTTP calls (§0) were missing from what this doc called "complete." Fixed by grepping `node_modules/@marketplaces/auth/dist/` directly. Directly from source (every `this.http.get/post/patch/put/delete` call across `src/app/core/`, `src/app/features/admin/`, `src/app/services/api.service.ts`, and now the auth package). This is not a design document — it is a **census**: every endpoint this codebase currently calls or will call once its gateway swap goes live, in one place, cross-referenced against the contracts that already exist.
|
||||
|
||||
**This is the final handoff doc for this pass.** Frontend work is done except one item that genuinely cannot be finished without a live backend (§19). Everything else — every gateway, every model, every invariant — is written, tested, and pushed. What follows is everything backend needs to make it real.
|
||||
|
||||
**Why this exists.** The individual Phase/Track contracts in this directory each cover one domain well. Nothing until now listed the *entire* surface in one pass, so a backend dev building against these docs had no way to see what's fully specified, what's inferred-and-needs-confirmation, and what has no contract at all. This closes that gap.
|
||||
|
||||
**Status legend**
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| ✅ Specified | Exact shape exists in a Phase/Track contract doc. Build as written. |
|
||||
| ⚠️ Inferred | Endpoint follows this codebase's own REST conventions (path pattern, verb) but no contract doc states it explicitly. Flagged in source with a comment at the call site. **Confirm or correct before building — do not treat as final.** |
|
||||
| ❌ Undocumented | Legacy endpoint, no contract anywhere, still called by `api.service.ts`. Will be replaced when the corresponding `/api/v2` migration lands (Track N) — do not invest in these long-term, but they are live today. |
|
||||
|
||||
---
|
||||
|
||||
## 0. Auth — lives in `@marketplaces/auth`, not this repo, and payment authorization runs through it
|
||||
|
||||
Two separate, real mechanisms. Both are called from the external package (`node_modules/@marketplaces/auth/dist/`), which is why §0 didn't exist in the first pass of this doc — that pass only grepped `src/app/`.
|
||||
|
||||
### 0.1 Telegram QR/session — ✅ Specified, **live**
|
||||
|
||||
Per `BACKEND-HANDOFF.md` §3 and `PACKAGES-USAGE.md`. **Customer and admin login call the same endpoints** — only client-side storage differs, which is exactly why every admin endpoint must independently verify authorization server-side (stated plainly in `BACKEND-HANDOFF.md` §3 as "the single most serious open issue in the system").
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `{authApiUrl}/users/sessions` | create a session from a Telegram login |
|
||||
| GET | `{authApiUrl}/users/sessions/{id}` | poll / check session status |
|
||||
| DELETE | `{authApiUrl}/users/sessions/{id}` | log out |
|
||||
|
||||
Expected response: `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }` — parsed permissively client-side (many key-name variants accepted), but a clean backend implementation should return exactly this shape.
|
||||
|
||||
### 0.2 Ed25519 admin challenge/response — specified, **not built**
|
||||
|
||||
Per `BACKEND-HANDOFF.md` §3 and the package's own `ed25519/models/auth-api.model.ts`. This is what backend-authorizes an admin session distinctly from a customer one, and it does not exist server-side yet — every request today shows a `backend-unavailable` screen once the client reaches for it, nothing is mocked.
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET | `/api/admin/auth/challenge` |
|
||||
| POST | `/api/admin/auth/verify` |
|
||||
| POST | `/api/admin/auth/refresh` |
|
||||
| POST | `/api/admin/auth/logout` |
|
||||
|
||||
### 0.3 What this has to do with payment
|
||||
|
||||
There is no separate "payment login." Authorization for anything payment-adjacent — creating a checkout session, viewing `GET /api/admin/v2/orders/{id}`'s pricing breakdown (§20), touching a partner credential (§16) — rides on whichever of the two sessions above is active. The partner-provisioning API (§16) has its **own**, separate signed-request auth (§6 of that contract, public-key based, no session token at all) — that one is unrelated to Telegram/ed25519 and is not a gap, it's a different, already-built mechanism for a different caller (partners, not our own admins or customers).
|
||||
|
||||
**The actual payment gap is §1 below, not auth**: QR/card payment creation and status polling are real, live, and completely undocumented anywhere in `docs/backend/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Legacy surface (still called today, no `/api/v2` contract)
|
||||
|
||||
These come from `BACKEND-API-REFERENCE.md`, not `docs/backend/`. Base URL is `environment.localhostApiUrl` / tenant-resolved; `qrBaseUrl` is a separate provider base for QR-specific calls.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/ping` | `api.service.ts` | ❌ Undocumented — health check |
|
||||
| GET | `/category` | `api.service.ts`, `api-category.repository.ts` | ❌ Undocumented — full category tree |
|
||||
| GET | `/category/{categoryID}` | `api.service.ts` | ❌ Undocumented — one category + its items |
|
||||
| GET | `/items/{itemID}` | `api.service.ts` | ❌ Undocumented — single item detail |
|
||||
| GET | `/searchitems` | `api.service.ts` | ❌ Undocumented — search |
|
||||
| GET | `/items/randomitems` | `api.service.ts` | ❌ Undocumented — related/random items |
|
||||
| POST | `/websession/{sessionId}` | `api.service.ts` | ❌ Undocumented — sync cart to a Telegram web session |
|
||||
| POST | `/items/{itemID}/callback` | `api.service.ts` | ❌ Undocumented — "call me back" request |
|
||||
| POST | `/items/{itemID}/questiion` | `api.service.ts` | ❌ Undocumented — product Q&A (note: `questiion` typo is load-bearing, do not silently "fix" without checking the live backend uses the same typo) |
|
||||
| POST | `/items/{itemID}/notify-me` | `api.service.ts` | ❌ Undocumented — back-in-stock subscription |
|
||||
| POST | `/purchase-email` | `api.service.ts` | ❌ Undocumented — post-purchase email collection |
|
||||
| POST | `/orders` | `api.service.ts` | ❌ Undocumented — records a paid cart as a backoffice order, fire-and-forget |
|
||||
|
||||
**Recommendation:** these 11 need their own contract doc if they are staying, or a deprecation timeline if `/api/v2/storefront/*` replaces them. Right now they are simply undocumented and live — the single biggest gap in `docs/backend/`.
|
||||
|
||||
**Removed 2026-08-21, no longer called by this codebase:** `POST {qrBaseUrl}/qr` (direct QR creation), `POST /cart` (legacy payment creation with client-sent `amount`), `GET {qrBaseUrl}/qr/dynamic/{partnerId}/{qrId}` and `GET {qrBaseUrl}/card/{partnerId}/{orderId}` (status polls). §2's checkout flow made these dead in code (`createPaymentIntent`) as of the earlier `@marketplaces/payment` integration commit; confirmed zero remaining callers and deleted the dead `ApiService` methods, request/response types, and unused fields (`qrBaseUrl`, `cartPaymentPartnerId`) in the same pass. If a real backend still serves these endpoints, they are now backend-only surface with no frontend caller — decide their fate independently of this doc.
|
||||
|
||||
---
|
||||
|
||||
## 2. Storefront checkout & cart — ✅ Specified
|
||||
|
||||
Contract: [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5.2, [PHASE-6-CART-CHECKOUT-CONTRACT.md](PHASE-6-CART-CHECKOUT-CONTRACT.md) §3, §5.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/v2/storefront/cart` | `server-cart-api.gateway.ts` |
|
||||
| POST | `/api/v2/storefront/cart/lines` | `server-cart-api.gateway.ts` |
|
||||
| PATCH | `/api/v2/storefront/cart/lines/{lineId}` | `server-cart-api.gateway.ts` |
|
||||
| DELETE | `/api/v2/storefront/cart/lines/{lineId}` | `server-cart-api.gateway.ts` |
|
||||
| POST | `/api/v2/storefront/checkout` | `server-cart-api.gateway.ts`, `api.service.ts` (two different callers, same contract) |
|
||||
| POST | `/api/v2/storefront/payments/intents` | `api.service.ts` |
|
||||
|
||||
## 3. Pricing — ✅ Specified
|
||||
|
||||
Contract: [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §3.1.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/v2/pricing/fx-quote?base="e=` | `fx-quote-api.gateway.ts` |
|
||||
|
||||
## 4. Identity & permissions — ✅ Specified
|
||||
|
||||
Contracts: [PHASE-8-IDENTITY-MESSAGING-CONTRACT.md](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) §2, [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) §2.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/identity/v1/vk/authorize` | `vk-id-api.gateway.ts` |
|
||||
| POST | `/api/identity/v1/vk/callback` | `vk-id-api.gateway.ts` |
|
||||
| GET | `/api/identity/v1/session/permissions` | `permission-api.gateway.ts` |
|
||||
|
||||
## 5. Team / RBAC — mixed
|
||||
|
||||
Contract: [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) §8.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/v2/audit` | `permission-api.gateway.ts`, `admin-users-api.gateway.ts?actor=` | ✅ Specified |
|
||||
| POST | `/api/admin/v2/team/invite` | `admin-users-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/team?marketplaceId=` | `admin-users-api.gateway.ts` | ✅ Specified |
|
||||
| PATCH | `/api/admin/v2/team/{userId}` | `admin-users-api.gateway.ts` | ✅ Specified |
|
||||
| DELETE | `/api/admin/v2/team/{userId}` | (interface exists, not yet called) | ✅ Specified |
|
||||
| GET | `/api/admin/v2/team/roles` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/team/invitations` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/team/{userId}/sessions` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
| PATCH | `/api/admin/v2/team/{userId}/status` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
| DELETE | `/api/admin/v2/team/invitations/{id}` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
| DELETE | `/api/admin/v2/team/sessions/{sessionId}` | `admin-users-api.gateway.ts` | ⚠️ Inferred |
|
||||
|
||||
## 6. Orders & notifications — ✅ Specified
|
||||
|
||||
Contract: [PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md).
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=` | `admin-orders-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/orders/{id}` | `admin-orders-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/orders/{id}/status` | `admin-orders-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/orders/{id}/refund-request` | `admin-orders-api.gateway.ts` (note: gateway calls this `refund-request`; interface method is named `requestRefund` — same endpoint) |
|
||||
| POST | `/api/admin/v2/orders/{id}/notes` | `admin-orders-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/orders/{id}/archive` | `admin-orders-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/orders/{id}/restore` | `admin-orders-api.gateway.ts` |
|
||||
| DELETE | `/api/admin/v2/orders/{id}` | `admin-orders-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/notifications?marketplaceId=&unreadOnly=&eventType=` | `admin-notifications-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/notifications/{id}/read` | `admin-notifications-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/notifications/read-all` | `admin-notifications-api.gateway.ts` | ⚠️ Inferred (bulk mark-read not in contract) |
|
||||
|
||||
## 7. Catalog / offers — ✅ Specified + ⚠️ Inferred
|
||||
|
||||
Contract: [PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) §7.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=` | `admin-products-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/products/{id}` | `admin-products-api.gateway.ts` | ✅ Specified |
|
||||
| POST | `/api/admin/v2/products` | `admin-products-api.gateway.ts` | ✅ Specified |
|
||||
| PATCH | `/api/admin/v2/products/{id}` | `admin-products-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/offers?productId=&sellerId=&status=` | `offer-api.gateway.ts` | ✅ Specified |
|
||||
| POST | `/api/admin/v2/offers/{id}/publish` | `offer-api.gateway.ts` | ✅ Specified — 422 + `details[]` on executability failure |
|
||||
| GET | `/api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=` | `offer-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/products/categories` | `admin-products-api.gateway.ts` | ⚠️ Inferred |
|
||||
| DELETE | `/api/admin/v2/products/{id}` | `admin-products-api.gateway.ts` | ⚠️ Inferred |
|
||||
| POST | `/api/admin/v2/products/{id}/duplicate` | `admin-products-api.gateway.ts` | ⚠️ Inferred |
|
||||
| POST | `/api/admin/v2/products/{id}/archive` | `admin-products-api.gateway.ts` | ⚠️ Inferred |
|
||||
| POST | `/api/admin/v2/products/{id}/restore` | `admin-products-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/offers/{offerId}/inventory` | `offer-api.gateway.ts` | ⚠️ Inferred |
|
||||
|
||||
## 8. Categories — ✅ Specified (pre-existing, F29 done before this session)
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `{baseUrl}` (categories collection) | `admin-categories-api.gateway.ts` |
|
||||
| GET | `{baseUrl}/{id}` | `admin-categories-api.gateway.ts` |
|
||||
| POST | `{baseUrl}` | `admin-categories-api.gateway.ts` |
|
||||
| PUT | `{baseUrl}/{id}` | `admin-categories-api.gateway.ts` |
|
||||
| DELETE | `{baseUrl}/{id}` | `admin-categories-api.gateway.ts` |
|
||||
| POST | `{baseUrl}/{id}/restore` | `admin-categories-api.gateway.ts` |
|
||||
| GET | `{baseUrl}/slug-taken` | `admin-categories-api.gateway.ts` |
|
||||
|
||||
## 9. Seller portal — mixed
|
||||
|
||||
Contract: [PHASE-5-SELLER-PORTAL-CONTRACT.md](PHASE-5-SELLER-PORTAL-CONTRACT.md) §3.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/seller/v1/finance/settlements` | (contract-only, no current caller) | ✅ Specified |
|
||||
| GET | `/api/admin/v2/sellers` | `seller-api.gateway.ts` | ⚠️ Inferred — contract only specifies the caller's own `GET /api/seller/v1/profile`, not an admin list-all |
|
||||
| GET | `/api/admin/v2/sellers/{sellerId}/team` | `seller-api.gateway.ts` | ⚠️ Inferred — contract's `GET /api/seller/v1/team` is session-scoped, not parameterized |
|
||||
|
||||
## 10. Payments, refunds, reconciliation, settlements — ✅ Specified
|
||||
|
||||
Contract: [PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md).
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/v2/orders/{orderId}/refunds` | `finance-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/reconciliation/queue` | `finance-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/reconciliation/{id}/resolve` | `finance-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/finance/settlements?sellerId=` | `finance-api.gateway.ts` |
|
||||
|
||||
## 11. Tenant registry — mixed
|
||||
|
||||
Contract: [PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md).
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/v2/marketplaces/{id}/lifecycle` | `marketplace-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/marketplaces` (list) | `marketplace-api.gateway.ts` | ⚠️ Inferred — contract specifies `POST` for creation, not the list `GET` |
|
||||
| GET | `/api/admin/v2/marketplaces/{id}/domains` | `marketplace-api.gateway.ts` | ⚠️ Inferred |
|
||||
|
||||
## 12. Connector framework — mixed
|
||||
|
||||
Contract: [PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) §7-8.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/v2/integrations` | `connector-api.gateway.ts` | ✅ Specified |
|
||||
| PATCH | `/api/admin/v2/integrations/{id}` | `connector-api.gateway.ts` (pause/resume via `status` field) | ✅ Specified |
|
||||
| GET | `/api/admin/v2/integrations/{connectorId}/dead-letter` | `connector-api.gateway.ts` | ⚠️ Inferred — contract specifies replay, not the list |
|
||||
| POST | `/api/admin/v2/integrations/{connectorId}/dead-letter/{id}/replay` | `connector-api.gateway.ts` | ✅ Specified — path corrected 2026-08-18; the gateway initially omitted `connectorId`, caught while writing this doc, fixed in the same pass |
|
||||
|
||||
## 13. Content modules (Gorbushka-class) — ⚠️ Mostly inferred
|
||||
|
||||
Contract: [PHASE-10-CONTENT-MODULES-CONTRACT.md](PHASE-10-CONTENT-MODULES-CONTRACT.md) §2.
|
||||
|
||||
| Method | Path | Called from | Status |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/admin/v2/content/rent-listings/{id}/leads` | `mall-content-api.gateway.ts` | ✅ Specified |
|
||||
| GET | `/api/admin/v2/content/shops` | `mall-content-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/content/shop-categories` | `mall-content-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/content/floors` | `mall-content-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/content/floors/{floorId}/pins` | `mall-content-api.gateway.ts` | ⚠️ Inferred |
|
||||
| GET | `/api/admin/v2/content/rent-listings` | `mall-content-api.gateway.ts` | ⚠️ Inferred |
|
||||
|
||||
## 14. Analytics — ✅ Specified
|
||||
|
||||
Contract: [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) §1.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| POST | `/api/v2/storefront/analytics/events` | `analytics-api.gateway.ts` |
|
||||
|
||||
## 15. Transactions, monitoring, moderation — ❌ No contract doc exists
|
||||
|
||||
These three domains have gateways calling `/api/admin/v2/{resource}` by convention only. No Phase/Track doc covers any of them.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/v2/transactions` | `admin-transactions-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/transactions/{id}/retry` | `admin-transactions-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/transactions/{id}` | `admin-transactions-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/monitoring/events` | `admin-monitoring-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/monitoring/queues` | `admin-monitoring-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/monitoring/webhooks` | `admin-monitoring-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/moderation/reviews` | `admin-moderation-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/moderation/reviews/{id}` | `admin-moderation-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/moderation/reviews/{id}` | `admin-moderation-api.gateway.ts` (status/visible/pinned/featured — 4 different PATCH bodies, same endpoint) |
|
||||
| POST | `/api/admin/v2/moderation/reviews/{id}/notes` | `admin-moderation-api.gateway.ts` |
|
||||
| DELETE | `/api/admin/v2/moderation/reviews/{id}` | `admin-moderation-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/moderation/reports` | `admin-moderation-api.gateway.ts` |
|
||||
| PATCH | `/api/admin/v2/moderation/reports/{id}` | `admin-moderation-api.gateway.ts` |
|
||||
| GET | `/api/admin/v2/dashboard/metrics` | `admin-dashboard-metrics-api.gateway.ts` |
|
||||
|
||||
**Recommendation: these are the highest-priority gap.** Three full admin domains with real UI and real gateways, zero backend contract. Whoever picks up Phase 5/7 next should write these as proper contract docs — the endpoint shapes above are a starting point, not a spec.
|
||||
|
||||
## 16. Partner provisioning API — ✅ Specified
|
||||
|
||||
Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) §4, §6. This is the one domain where the frontend gateway was built *from* the contract, not the other way around — no drift to reconcile.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/partner/v1/companies/{companyId}/hierarchy?environment=` | `partner-hierarchy-api.gateway.ts` |
|
||||
| GET | `/api/partner/v1/nodes/{nodeId}` | `partner-hierarchy-api.gateway.ts` |
|
||||
| GET | `/api/partner/v1/nodes/lookup?externalReference=&environment=` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/companies/{companyId}/projects` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/projects/{projectId}/stores` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/stores/{storeId}/payment-points` | `partner-hierarchy-api.gateway.ts` |
|
||||
| PATCH | `/api/partner/v1/nodes/{nodeId}/status` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/nodes/{nodeId}/disable` | `partner-hierarchy-api.gateway.ts` |
|
||||
| GET | `/api/partner/v1/credentials?companyId=` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/credentials` | `partner-hierarchy-api.gateway.ts` |
|
||||
| POST | `/api/partner/v1/credentials/{keyId}/rotate` | `partner-hierarchy-api.gateway.ts` |
|
||||
| DELETE | `/api/partner/v1/credentials/{keyId}` | `partner-hierarchy-api.gateway.ts` |
|
||||
|
||||
## 17. Backoffice data & bootstrap — ✅ Specified elsewhere (not a Phase/Track doc, but stable)
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| GET | `/api/backoffice/products` | `api-backoffice-data.provider.ts` |
|
||||
| GET | `/api/backoffice/categories` | `api-backoffice-data.provider.ts` |
|
||||
| GET | (tenant bootstrap URL) | `api-bootstrap.provider.ts` — see `BACKEND-HANDOFF.md` §1a |
|
||||
|
||||
## 18. Marketplace publish/revision model — ✅ Specified
|
||||
|
||||
Contract: [PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §5. Frontend core built (`marketplace-revision-*.gateway.ts`), not yet wired into the project editor's UI — see §20.
|
||||
|
||||
| Method | Path | Called from |
|
||||
|---|---|---|
|
||||
| POST | `/api/admin/v2/marketplaces/{id}/revisions` | `marketplace-revision-api.gateway.ts` — create draft |
|
||||
| POST | `/api/admin/v2/marketplaces/{id}/revisions/{revId}/validate` | `marketplace-revision-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/marketplaces/{id}/revisions/{revId}/publish` | `marketplace-revision-api.gateway.ts` |
|
||||
| POST | `/api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback` | `marketplace-revision-api.gateway.ts` — creates a NEW revision, never mutates the old one |
|
||||
|
||||
**One real ambiguity in §5 itself, flagged for confirmation, not guessed silently:** the pipeline is described as 4 stages (`draft → validated → preview → publish`) but only 3 write endpoints exist (validate/publish/rollback) — there is no dedicated "move to preview" call. The frontend model assumes `POST .../validate` moves a revision straight to `preview` (the state `publish` requires), treating `validated` as a value the caller may never observe. **Confirm this is correct before implementing** — if the real response returns `status: 'validated'` and requires a separate step to reach `preview`, the frontend model and this doc both need updating.
|
||||
|
||||
## 19. RoutingContext on payments — carried through, not yet backend-verified
|
||||
|
||||
Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) §7, [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6.5. Not a separate endpoint — a field addition backend needs to populate on existing responses:
|
||||
|
||||
```ts
|
||||
routing?: {
|
||||
companyId: string;
|
||||
routingPath: string[];
|
||||
leafNodeId: string;
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string;
|
||||
providerPaymentId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Frontend added this as an **optional** field on `AdminOrder` (`admin-order.model.ts`) and renders it on the order detail page when present. Nothing breaks if it's absent — but nothing shows the payment-point attribution either until backend populates it on `GET /api/admin/v2/orders/{id}`.
|
||||
|
||||
## 20. Order pricing breakdown — same pattern, different fields
|
||||
|
||||
Contract: [PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5.3. Optional fields added to `AdminOrderItem` and `AdminOrder`:
|
||||
|
||||
```ts
|
||||
// per line
|
||||
unitPriceMinor?: number;
|
||||
lineTotalMinor?: number;
|
||||
priceSnapshotId?: string;
|
||||
discountMinor?: number;
|
||||
|
||||
// per order
|
||||
fxQuoteId?: string;
|
||||
deliveryMinor?: number;
|
||||
```
|
||||
|
||||
Frontend renders a "total formula" panel on the order detail page (`order-total-formula.component.ts`) reconstructing `total = sum(unitPrice*qty) - discounts + delivery`, with the FX quote used. **It refuses to show a partial breakdown** — every line must carry `unitPriceMinor` or the panel says "not available" instead of a number that's silently wrong. Populate these fields on `GET /api/admin/v2/orders/{id}` to make it real; until then it correctly shows nothing.
|
||||
|
||||
## 21. Dashboard metrics — 8 new optional fields, no shape confirmed
|
||||
|
||||
Contract intent: [PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §7's target metric list — no contract doc defines `GET /api/admin/v2/dashboard/metrics`'s response shape (§15 already flagged this endpoint has no spec at all). Frontend added these as optional fields on `AdminDashboardMetrics`, each rendering `'unknown'` (not a fabricated `'healthy'`) while absent:
|
||||
|
||||
```ts
|
||||
gmvMinor?: number;
|
||||
currency?: string;
|
||||
paidOrdersCount?: number;
|
||||
conversionRate?: number; // 0-1, not a percentage
|
||||
paymentFailureRate?: number; // 0-1
|
||||
moderationQueueCount?: number;
|
||||
lowStockCount?: number;
|
||||
unmatchedEventsCount?: number;
|
||||
integrationHealthyCount?: number;
|
||||
integrationTotalCount?: number;
|
||||
```
|
||||
|
||||
This is a genuine ask, not a confirmed contract — §15's recommendation to write a real spec for this endpoint still stands. These field names are what the frontend already expects; change them here first if backend needs different ones.
|
||||
|
||||
## 22. Idempotency — frontend now guards, backend enforcement is still required
|
||||
|
||||
The frontend added a client-side guard against double-submitting checkout (a real bug: double-clicking used to fire two `POST /api/v2/storefront/checkout` calls — fixed in `cart.component.ts`, `checkoutInFlight` signal). **This does not replace backend idempotency and was never meant to.** It closes one specific UI race; it does nothing for a retried request from a flaky network, a backgrounded tab resuming, or an actual duplicate webhook delivery. Contract requirements are unchanged and still required:
|
||||
|
||||
- `PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §6.3 — webhook idempotency key = `provider + providerEventId`
|
||||
- `PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §6.4 — order creation idempotency key = `checkoutSessionId`
|
||||
- `PARTNER-PROVISIONING-API-CONTRACT.md` §5 — `Idempotency-Key` header semantics for all partner-API creates
|
||||
|
||||
---
|
||||
|
||||
## Summary counts
|
||||
|
||||
| Status | Count |
|
||||
|---|---|
|
||||
| ✅ Specified | 54 |
|
||||
| ⚠️ Inferred (needs confirmation) | 24 |
|
||||
| ❌ Undocumented (legacy) | 11 |
|
||||
| **Total distinct endpoints called** | **93** |
|
||||
|
||||
(§0's 3 Telegram endpoints count as Specified+live; its 4 ed25519 endpoints count as Specified+not-built — both are real, named endpoints with a known shape, distinct from §1's "no contract exists anywhere" undocumented status.)
|
||||
|
||||
Plus 3 response-shape additions (§19–21) on existing endpoints — not new endpoints, new optional fields on responses those endpoints already return.
|
||||
|
||||
## What backend needs to do with this
|
||||
|
||||
0. **§0.2 first, ahead of everything else on this list.** The 4 ed25519 admin auth endpoints are the single most serious open issue named anywhere in `docs/backend/` (`BACKEND-HANDOFF.md` §3's own words). Every other item below assumes an admin session exists to authorize the request — that session mechanism does not exist server-side yet.
|
||||
1. **Build the ✅ rows as written** — they match an existing contract doc exactly. This includes §18's 4 revision endpoints, pending confirmation on the `validated`/`preview` ambiguity called out there.
|
||||
2. **Confirm or correct every ⚠️ row** — each one has a comment at its call site in source explaining the inference. Search the codebase for `Inferred` to find all 24 in place, with the reasoning right next to the code.
|
||||
3. **Populate the 3 optional field sets (§19–21)** on the endpoints that already exist — `routing` and pricing-breakdown fields on `GET /api/admin/v2/orders/{id}`, the 8 metric fields on `GET /api/admin/v2/dashboard/metrics`. Nothing on the frontend breaks while these are absent; nothing shows the real data either.
|
||||
4. **Write a contract for §15** (transactions, monitoring, moderation) — real UI, real gateways, zero spec. Highest-priority gap in this whole list.
|
||||
5. **Decide the fate of §1** — 15 legacy endpoints with no contract at all, still live. Either document them as a stable, permanent surface, or set a Track N migration date.
|
||||
6. **Enforce idempotency server-side regardless of §22** — the frontend's double-click guard is a UI nicety, not a substitute for `Idempotency-Key`/`providerEventId` dedup.
|
||||
|
||||
## One item that stays blocked until a real backend exists
|
||||
|
||||
The frontend backlog (F1–F65, tracked this session) is complete except **F60: the full acceptance-path E2E** — seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment, in one test, against real infrastructure. Every other item is written, unit-tested, E2E-covered where an E2E test could prove something real, and pushed. This one cannot be honestly finished by mocking harder — it needs an actual backend to run against. Once even a minimal version of Phases 1–4 is live, this is the next thing to build (`docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md` Track Q, Q3).
|
||||
@@ -1,323 +0,0 @@
|
||||
# Partner Provisioning API Contract — Merchant Hierarchy, Credentials, Routing
|
||||
|
||||
Cross-cutting contract. Partner-facing, **inbound**: external partners call us. Distinct from [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md), which is outbound/ingest.
|
||||
|
||||
Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (payment state machine), [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (seller org), [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) (marketplace registry), [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) (keys, audit, rate limiting).
|
||||
|
||||
**Status: draft — level mapping decided (§10), two new entities required.**
|
||||
|
||||
Origin: a partner integration request (2026-08-18). **This contract is deliberately generic.** No partner name appears in any entity, field, endpoint, or status value. Partner-specific behaviour lives entirely in a `PartnerProfile` config row (§8). A second partner asking for the same thing must require zero schema and zero endpoint change.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Partners need to provision and manage their own merchant hierarchy programmatically, then have payments route unambiguously back to the correct leaf. Today we have no partner-facing write API at all, no entity above `Marketplace`, and payments carry no store dimension — reconciliation cannot attribute a payment to a store.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hierarchy model
|
||||
|
||||
Four levels, fixed. Middle levels are **optional per partner**, never free-form depth.
|
||||
|
||||
```
|
||||
Company -> Project -> Store -> PaymentPoint
|
||||
```
|
||||
|
||||
```ts
|
||||
type NodeLevel = 'company' | 'project' | 'store' | 'payment_point';
|
||||
|
||||
interface ProvisioningNode {
|
||||
id: string; // stable, opaque, never reused
|
||||
level: NodeLevel;
|
||||
parentId: string | null; // null only for level 'company'
|
||||
companyId: string; // denormalized root, present on every node
|
||||
path: string[]; // ordered ancestor ids, root first, inclusive of self
|
||||
environment: Environment;
|
||||
status: NodeStatus;
|
||||
externalReference: string; // partner's own id for this node
|
||||
displayName: string;
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
}
|
||||
|
||||
type Environment = 'TEST' | 'LIVE';
|
||||
type NodeStatus = 'active' | 'suspended' | 'disabled';
|
||||
```
|
||||
|
||||
### Invariants
|
||||
|
||||
1. `parentId` must be the immediately preceding **enabled** level in the partner's profile. Skipping a required level is `422`.
|
||||
2. `companyId` and `environment` are inherited from the parent and are immutable.
|
||||
3. `externalReference` is unique per `(companyId, environment, level)`. Collision is `409`.
|
||||
4. `path` is server-computed. Never accepted from the client.
|
||||
5. A node cannot be re-parented. Ever. Move = disable + create new.
|
||||
6. Creating any node **never** enables a financial capability, never creates a payment, never opens a settlement account. Financial enablement is a separate, explicitly approved flow outside this contract.
|
||||
|
||||
### Status semantics
|
||||
|
||||
| Status | Meaning | Accepts payments | Reversible |
|
||||
|---|---|---|---|
|
||||
| `active` | normal | yes | — |
|
||||
| `suspended` | temporarily halted | no | yes, back to `active` |
|
||||
| `disabled` | terminal | no | no |
|
||||
|
||||
- Disabling a node cascades `disabled` to every descendant, atomically.
|
||||
- Suspending a node cascades `suspended` to descendants; un-suspending restores **only** descendants that were suspended by that same cascade (tracked by cascade id), never descendants suspended independently.
|
||||
- `disabled` never returns to any other status. Re-provisioning creates a new node with a new id.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environments
|
||||
|
||||
`TEST` and `LIVE` are a hard partition:
|
||||
|
||||
- Separate credentials. A `TEST` key can never address a `LIVE` node, and vice versa — cross-environment access is `403`, not `404`.
|
||||
- Node ids never collide across environments and are never transferable.
|
||||
- `externalReference` uniqueness is scoped per environment — the same partner reference may exist once in each.
|
||||
- No data, config, or hierarchy copy between environments in this API.
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
Namespace `/api/partner/v1/`. All timestamps ISO 8601 UTC.
|
||||
|
||||
### 4.1 Write
|
||||
|
||||
```
|
||||
POST /api/partner/v1/companies/{companyId}/projects
|
||||
POST /api/partner/v1/projects/{projectId}/stores
|
||||
POST /api/partner/v1/stores/{storeId}/payment-points
|
||||
|
||||
PATCH /api/partner/v1/nodes/{nodeId}/status -- { status: 'active' | 'suspended', reason?: string }
|
||||
POST /api/partner/v1/nodes/{nodeId}/disable -- terminal, cascading
|
||||
```
|
||||
|
||||
Creation body:
|
||||
|
||||
```ts
|
||||
interface CreateNodeRequest {
|
||||
externalReference: string;
|
||||
displayName: string;
|
||||
metadata?: Record<string, string>; // opaque to us, echoed back, never interpreted
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Read
|
||||
|
||||
```
|
||||
GET /api/partner/v1/nodes/{nodeId}
|
||||
GET /api/partner/v1/companies/{companyId}/hierarchy?environment=TEST|LIVE&status=...&depth=...
|
||||
GET /api/partner/v1/nodes/lookup?externalReference=...&level=...&environment=...
|
||||
GET /api/partner/v1/companies/{companyId}/audit?from=...&to=...&cursor=...
|
||||
```
|
||||
|
||||
- `hierarchy` returns the full tree with current statuses, one call, cursor-paginated over nodes when large.
|
||||
- `lookup` is the `externalReference` resolver. Returns `404` when unmatched — never a partial or fuzzy match.
|
||||
- Read endpoints are the partner's own verification surface for what was actually created. They read from the same store as writes — never a cache that can lag behind a create.
|
||||
|
||||
### 4.3 Not in this API
|
||||
|
||||
Company creation. A `Company` is created by us during commercial onboarding, out of band. Partners provision **inside** a company they already have.
|
||||
|
||||
---
|
||||
|
||||
## 5. Idempotency
|
||||
|
||||
Every `POST` requires an `Idempotency-Key` header. `PATCH` status changes accept one optionally.
|
||||
|
||||
```
|
||||
Idempotency-Key: <partner-generated, opaque, <=255 chars>
|
||||
```
|
||||
|
||||
Rules, in order:
|
||||
|
||||
1. Key scope is `(partnerId, endpoint, key)`. Two partners may use the same key string without interference.
|
||||
2. Same key + byte-identical request body → the **original stored response** is replayed, with the original status code. No new node.
|
||||
3. Same key + different body → `409 Conflict`, error code `idempotency_key_reuse`. Nothing is created or modified.
|
||||
4. Retention: 24 hours from first use. After expiry the key is free again — partners must not rely on replay beyond 24h.
|
||||
5. A request that arrives while an identical key is still in flight returns `409` with `idempotency_request_in_progress`. Partner retries after a short backoff.
|
||||
6. **No partial hierarchy.** A creation request either commits its node fully or commits nothing. If a partner creates project → store → payment point in three calls and the third fails, the first two remain — that is three operations, each atomic. A single call is never partially applied.
|
||||
|
||||
Body comparison uses a canonical hash (sorted keys, normalized whitespace) so key ordering does not cause a false `409`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Credentials and key management
|
||||
|
||||
### 6.1 Model — answered generically
|
||||
|
||||
Partners asked whether a credential is per-company, per-project, or per-store. **All three, one mechanism:** a credential is bound to **any single node**, and its authority is that node's subtree.
|
||||
|
||||
```ts
|
||||
interface PartnerCredential {
|
||||
partnerId: string;
|
||||
keyId: string;
|
||||
scopeNodeId: string; // credential may act on this node and all descendants
|
||||
environment: Environment;
|
||||
algorithm: 'ed25519' | 'rsa-pss-sha256';
|
||||
publicKey: string; // PEM or base64 raw, per algorithm
|
||||
status: 'active' | 'rotating' | 'revoked';
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- Partner generates the keypair. The **private key never leaves the partner** and is never transmitted to us, never logged, never accepted by any endpoint.
|
||||
- Partner registers the public key; we return `partnerId` + `keyId`.
|
||||
- Authority is strictly the `scopeNodeId` subtree. Any request touching a node outside it is `403`.
|
||||
- A credential can never widen its own scope, register another credential at a wider scope, or create a node above its scope.
|
||||
|
||||
### 6.2 Endpoints
|
||||
|
||||
```
|
||||
POST /api/partner/v1/credentials -- register public key, returns partnerId + keyId
|
||||
GET /api/partner/v1/credentials
|
||||
POST /api/partner/v1/credentials/{keyId}/rotate -- register successor public key, overlap window
|
||||
DELETE /api/partner/v1/credentials/{keyId} -- revoke, effective immediately
|
||||
```
|
||||
|
||||
- **Rotation:** the successor key is registered while the current key stays valid for a bounded overlap (default 7 days, configurable per profile). Both keys verify during overlap. The predecessor auto-revokes at window end.
|
||||
- **Revocation is immediate and irreversible.** In-flight requests signed with a revoked key fail. Revoking a credential does not touch any node it created.
|
||||
- Registration, rotation, and revocation each emit a Track S audit event. Key lifecycle actions are always attributable to a named actor.
|
||||
|
||||
### 6.3 Request authentication
|
||||
|
||||
Requests are signed, not bearer-token'd:
|
||||
|
||||
- Signature covers: HTTP method, path, canonical body hash, `Idempotency-Key` (when present), and a timestamp.
|
||||
- Timestamp skew tolerance ±5 minutes. Outside that → `401`.
|
||||
- Signature replay within the window is rejected by nonce tracking → `401`.
|
||||
- `keyId` travels in the signature header so we select the right public key without trusting the body.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payment routing
|
||||
|
||||
Every payment, callback, refund, and settlement row carries a routing context.
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf, resolves to exactly one leaf
|
||||
leafNodeId: string; // convenience: last element of routingPath
|
||||
environment: Environment;
|
||||
merchantReference: string; // partner-supplied, opaque to us, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
### Invariants
|
||||
|
||||
1. `routingPath` must resolve to exactly one leaf node. Ambiguous or unresolvable → the payment is rejected at creation, never accepted and reconciled later.
|
||||
2. `merchantReference` is stored verbatim and echoed on **every** downstream event: payment status change, refund, settlement line, webhook.
|
||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
||||
4. Routing context is immutable for the life of the payment. Node status changes afterwards never rewrite it.
|
||||
|
||||
### Contract amendments this requires
|
||||
|
||||
`RoutingContext` must be added to:
|
||||
|
||||
- [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — `Payment`, `PaymentEvent`, checkout session
|
||||
- [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) — refund, reconciliation row, settlement line
|
||||
|
||||
**Do this before backend implements Phase 1.** Retrofitting a routing dimension onto a live payments table is materially more expensive than adding it now.
|
||||
|
||||
Partner-facing serialization uses the partner's own field names (§8) — `routingPath` is emitted as `projectId`/`storeId`/`paymentPointId` for a partner using those terms, without the core model knowing those words.
|
||||
|
||||
---
|
||||
|
||||
## 8. Partner profile — the only partner-specific surface
|
||||
|
||||
```ts
|
||||
interface PartnerProfile {
|
||||
partnerId: string;
|
||||
requiredLevels: NodeLevel[]; // subset; 'company' and the leaf are always required
|
||||
levelAliases: Record<NodeLevel, string>; // e.g. { project: 'Project', store: 'Store' }
|
||||
routingFieldNames: Record<NodeLevel, string>; // e.g. { store: 'storeId' }
|
||||
rateLimitTier: string;
|
||||
keyRotationOverlapDays: number;
|
||||
webhookFieldMap?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
A partner with no "project" concept omits it from `requiredLevels`; their stores hang directly off the company and the hierarchy still validates. A partner calling stores "branches" changes one alias. **Adding a partner is a config row, not a deployment.**
|
||||
|
||||
What is deliberately **not** configurable, because configurability here breaks reconciliation or safety:
|
||||
|
||||
- `NodeStatus` values and their transition rules
|
||||
- Idempotency semantics
|
||||
- Environment partitioning
|
||||
- Signature scheme and skew tolerance
|
||||
- The four-level ceiling
|
||||
|
||||
---
|
||||
|
||||
## 9. Operational requirements
|
||||
|
||||
| Requirement | Contract |
|
||||
|---|---|
|
||||
| OpenAPI | Machine-readable spec published per version, generated from the implementation, never hand-maintained |
|
||||
| Sandbox | `TEST` environment is the sandbox. Same code path as `LIVE`, isolated data, no real money |
|
||||
| Error codes | Stable string codes, documented, never renamed. HTTP status + `code` + human `message` + `requestId` |
|
||||
| Rate limits | Per `partnerId`, per tier. `429` with `Retry-After`. Limits published in the spec, per Track S |
|
||||
| Audit | Every write is an audit event: actor (`keyId`), action, target node, before/after status, `requestId`, timestamp. Immutable, queryable via §4.2 |
|
||||
| Idempotency observability | `Idempotency-Replayed: true` response header when a stored response is replayed |
|
||||
|
||||
### Error codes
|
||||
|
||||
```
|
||||
validation_failed 422
|
||||
parent_not_found 404
|
||||
level_skipped 422
|
||||
external_reference_conflict 409
|
||||
idempotency_key_reuse 409
|
||||
idempotency_request_in_progress 409
|
||||
scope_forbidden 403
|
||||
environment_mismatch 403
|
||||
node_disabled 409
|
||||
signature_invalid 401
|
||||
signature_expired 401
|
||||
rate_limited 429
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Mapping onto our model
|
||||
|
||||
Decided 2026-08-18.
|
||||
|
||||
| Partner level | Our entity | State |
|
||||
|---|---|---|
|
||||
| `company` | — | **New.** No entity above `Marketplace` exists today. Legal/commercial owner, created out of band (§4.3). |
|
||||
| `project` | — | **New.** A product line, e.g. `marketplaces`. One company runs several. Not the same thing as a `Marketplace`. |
|
||||
| `store` | `Marketplace` ([Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)) | Exists. Gains `companyId`, `projectId`, `externalReference`. |
|
||||
| `payment_point` | — | **New.** An acceptance channel: one payment method bound to one store. Many per store. |
|
||||
|
||||
### 10.1 PaymentPoint = acceptance channel
|
||||
|
||||
A `PaymentPoint` is a payment method enabled on a store, not a physical location and not a settlement account.
|
||||
|
||||
```ts
|
||||
interface PaymentPointConfig {
|
||||
method: PaymentMethod; // 'qr' | 'card', extensible
|
||||
currencies: string[]; // ISO 4217 subset the channel accepts
|
||||
providerAccountRef?: string; // opaque provider-side binding, set during financial enablement
|
||||
}
|
||||
```
|
||||
|
||||
Both current methods ship today — `src/app/pages/cart/cart.component.ts` (`PaymentMethod = 'qr' | 'card'`, separate create + status-poll paths per method). A store accepting both has two payment points.
|
||||
|
||||
Creating a payment point registers the channel. It does **not** enable it for real money — §2 invariant 6 still holds. Financial enablement sets `providerAccountRef` through a separate approved flow.
|
||||
|
||||
### 10.2 Seller is orthogonal
|
||||
|
||||
`Seller` ([Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md)) is **not** a level in this hierarchy. A multi-seller marketplace is one `store` with many sellers underneath; seller-level settlement splitting happens in [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) reconciliation, after the payment has already been routed to the store. Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most will not have.
|
||||
|
||||
### 10.3 Consequences
|
||||
|
||||
1. Two new entities: `Company`, `Project`. Both are thin — id, name, `externalReference`, status, timestamps — and both sit above `Marketplace`.
|
||||
2. `Marketplace` gains `companyId` + `projectId`. Existing marketplaces need a backfill company and project.
|
||||
3. `PaymentPoint` is new and is what `routingPath` terminates at (§7).
|
||||
4. §7's contract amendments to Phases 1 and 7 do not depend on any of the above — start them now.
|
||||
@@ -1,256 +0,0 @@
|
||||
# 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.1–1.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. Backend ownership was answered 2026-08-18 — a separate backend developer builds against it.
|
||||
|
||||
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"e=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.
|
||||
|
||||
### 6.5 Routing context
|
||||
|
||||
Added 2026-08-18. Full definition in [PARTNER-PROVISIONING-API-CONTRACT.md §7](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf
|
||||
leafNodeId: string; // the payment point money is accepted at
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string; // partner-supplied, opaque, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
`RoutingContext` is a **required** field on `CheckoutSession`, `PaymentIntent`, and `Payment`. `PaymentEvent` does not carry its own copy — it inherits via `paymentIntentId` — but every event **emitted** to the bus or to a partner must include the resolved context so consumers never need a second lookup.
|
||||
|
||||
Invariants:
|
||||
|
||||
1. Resolved and frozen at checkout-session creation. Immutable for the life of the payment. Later node status changes never rewrite it.
|
||||
2. `routingPath` must resolve to exactly one leaf. Ambiguous or unresolvable → reject at creation. Never accept a payment and resolve routing during reconciliation.
|
||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
||||
4. `merchantReference` is stored verbatim, never parsed, never normalized.
|
||||
5. `environment` must match the credential's environment. Mismatch is `403`.
|
||||
|
||||
**This is why it lands now, not later.** Without it, a payment cannot be attributed to a store, and §5's reconciliation goal — reconstructing why a given amount was charged — stops one level short of who it was charged for. Adding a routing dimension to a populated payments table after launch is materially more expensive than carrying it from the first row.
|
||||
|
||||
---
|
||||
|
||||
## 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 — answered 2026-08-18.** A separate backend developer implements against this contract. Note §6.5: `RoutingContext` must be carried from the first payment row, not retrofitted.
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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.1–10.2). Covers plan §11.
|
||||
|
||||
**Status: ready to build, lowest priority.** Only after Commerce Core (Phases 1–7) 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.
|
||||
@@ -1,152 +0,0 @@
|
||||
# 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.1–2.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.
|
||||
@@ -1,144 +0,0 @@
|
||||
# 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.1–3.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.
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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 5–8) 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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.1–5.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.
|
||||
@@ -1,90 +0,0 @@
|
||||
# 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.1–6.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.
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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.1–7.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;
|
||||
routing: RoutingContext; // copied verbatim from the original Payment, never recomputed
|
||||
}
|
||||
```
|
||||
|
||||
A refund always carries the routing context of the payment it reverses. It is copied, not re-resolved — a store suspended after the payment must still be refundable.
|
||||
|
||||
```
|
||||
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;
|
||||
routing: RoutingContext; // from the Payment; makes every row attributable to one payment point
|
||||
}
|
||||
```
|
||||
|
||||
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=&companyId=&projectId=&leafNodeId=&result=
|
||||
POST /api/admin/v2/reconciliation/{id}/resolve { note }
|
||||
```
|
||||
|
||||
Step 3's `merchant_reference` strategy matches on `RoutingContext.merchantReference` — the partner-supplied value, stored verbatim (Phase 1 §6.5). The queue is filterable at every hierarchy level so an unmatched set can be narrowed to one payment point without a join the backoffice has to build itself.
|
||||
|
||||
## 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=&companyId=&projectId=&storeId=&period=
|
||||
```
|
||||
|
||||
### 3.1 Seller split happens after routing
|
||||
|
||||
Added 2026-08-18. `Seller` is deliberately **not** a level in the partner hierarchy ([PARTNER-PROVISIONING-API-CONTRACT.md §10.2](PARTNER-PROVISIONING-API-CONTRACT.md)). Order of operations:
|
||||
|
||||
```
|
||||
payment -> routed to exactly one payment point (Phase 1 §6.5, frozen at checkout)
|
||||
-> reconciled at that payment point
|
||||
-> split across the sellers whose lines the order contains (this phase)
|
||||
```
|
||||
|
||||
- A `Settlement` belongs to one seller **within one store**. A seller trading in two stores gets two settlements per period, never one merged row.
|
||||
- Splitting never rewrites `RoutingContext`. The money arrived at one payment point; the split decides who is owed from it.
|
||||
- `grossAmount` summed across a store's settlements for a period must reconcile against that store's matched reconciliation rows for the same period. A mismatch is a reconciliation defect, not a rounding tolerance.
|
||||
|
||||
## 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.
|
||||
@@ -1,151 +0,0 @@
|
||||
# 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.1–8.5). Covers plan §2.9, §3.4, and all of §14 (the v3.1-only addition).
|
||||
|
||||
**Status: ready to build. Sprint order fixed by Sprint 0.1 decision: VK ID first, then everything else** ("do all after vk"). Sequence below follows that: identity core → VK ID → email/phone OTP → MAX/Telegram → Notification Orchestrator.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entities
|
||||
|
||||
```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).
|
||||
@@ -1,196 +0,0 @@
|
||||
# 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.1–9.3). Covers plan §4.3, §8.
|
||||
|
||||
**Status: ready to build.** Zero `hostinger` references exist in the codebase today.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entities
|
||||
|
||||
Added 2026-08-18: two levels now sit **above** `Marketplace`, introduced by [PARTNER-PROVISIONING-API-CONTRACT.md §10](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
```ts
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
externalReference?: string; // partner's own id, when provisioned via the partner API
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string; // a product line, e.g. "marketplaces"
|
||||
externalReference?: string;
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Both are deliberately thin — they exist to scope ownership, credentials, and payment routing, not to hold configuration. All marketplace configuration stays on `Marketplace` below.
|
||||
|
||||
A `Marketplace` **is** the partner hierarchy's `store` level. One project holds many marketplaces; one marketplace holds many sellers (Phase 5), and sellers are not part of that hierarchy.
|
||||
|
||||
```ts
|
||||
interface Marketplace {
|
||||
id: string;
|
||||
companyId: string; // added 2026-08-18
|
||||
projectId: string; // added 2026-08-18
|
||||
externalReference?: string; // added 2026-08-18, partner's own id for this store
|
||||
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.
|
||||
|
||||
### 1.1 PaymentPoint
|
||||
|
||||
Added 2026-08-18. The leaf of the partner hierarchy: one payment method accepted at one marketplace. A marketplace taking both QR and card has two payment points.
|
||||
|
||||
```ts
|
||||
interface PaymentPoint {
|
||||
id: string;
|
||||
marketplaceId: string;
|
||||
method: 'qr' | 'card'; // extensible; both ship today
|
||||
currencies: string[]; // ISO 4217 subset this channel accepts
|
||||
externalReference?: string;
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
providerAccountRef?: string; // set only by financial enablement, never by provisioning
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
- Creating a payment point registers the channel. It does **not** enable real money — that requires `providerAccountRef`, set through a separate approved flow.
|
||||
- A payment point is what `RoutingContext.leafNodeId` points at (Phase 1 §6.5).
|
||||
- `MarketplaceFeatureSet.features.payments` gates whether the marketplace may have enabled payment points at all; the payment point gates which method.
|
||||
|
||||
### 1.2 Backfill
|
||||
|
||||
Existing marketplaces predate `Company` and `Project`. Migration, in this order:
|
||||
|
||||
```
|
||||
1. Create one Company for the current owning entity.
|
||||
2. Create one Project ("marketplaces") under it.
|
||||
3. Set companyId + projectId on every existing Marketplace.
|
||||
4. Create PaymentPoints for the methods each marketplace already accepts (qr, card).
|
||||
5. Make companyId and projectId non-nullable only after 3 completes.
|
||||
```
|
||||
|
||||
`externalReference` stays null for backfilled rows — it is only meaningful for partner-provisioned nodes.
|
||||
|
||||
## 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).
|
||||
@@ -1,49 +0,0 @@
|
||||
# Backend Contracts Index — Product Plan v3.1
|
||||
|
||||
> **New here? Start with [BACKEND-HANDOFF.md](BACKEND-HANDOFF.md)** — reading order, current infrastructure state, auth surface, and what a working dev environment still needs.
|
||||
>
|
||||
> **Implementing tenant routing/nginx? [TENANT-API-DOMAIN-HANDOFF.md](TENANT-API-DOMAIN-HANDOFF.md)** is the final host normalization, CORS, TLS, reverse-proxy, CI-secret, and acceptance contract.
|
||||
>
|
||||
> **Want every endpoint in one place? [FRONTEND-API-SURFACE-COMPLETE.md](FRONTEND-API-SURFACE-COMPLETE.md)** — the final handoff doc. Generated directly from source, all 90 endpoints the frontend currently calls plus 3 response-shape additions on existing endpoints, marked Specified / Inferred / Undocumented against the contracts below. Use it to see gaps across all contracts at once; use the individual Phase/Track docs for full entity shapes and invariants.
|
||||
|
||||
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 |
|
||||
| [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) | Inbound partner API: merchant hierarchy provisioning, idempotency, public-key credentials, payment routing context | Draft — mapping decided, needs Company/Project entities |
|
||||
|
||||
## 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 — answered 2026-08-18.** A separate backend developer implements against these contracts. This repository's team owns the frontend and owns *this contract set* — the docs here are the handoff surface between the two, so a change to any contract is a change both sides must see. Keep them current; they are not a one-time deliverable.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Final handoff: tenant API domains
|
||||
|
||||
This is the required production contract between the shared frontend, nginx,
|
||||
and the backend. It supersedes any fixed `api.dexarmarket.ru` or same-origin
|
||||
`/backend` routing proposal.
|
||||
|
||||
## 1. Deterministic hostname rule
|
||||
|
||||
The frontend uses one API hostname per **base domain**:
|
||||
|
||||
| Storefront | API origin | Bootstrap |
|
||||
|---|---|---|
|
||||
| `example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
| `store1.example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
| `www.example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
|
||||
Bootstrap, auth, legacy routes, and `/api/...` routes all use this origin.
|
||||
Localhost is the only exception and continues through the local `/api` proxy.
|
||||
|
||||
## 2. Backend changes required
|
||||
|
||||
For every request received publicly on the shared `api.<base-domain>`:
|
||||
|
||||
1. Behind the trusted project nginx, use `X-Storefront-Host`. nginx derives it
|
||||
from a validated browser `Origin`, sends the same value as upstream `Host`,
|
||||
and preserves the shared public API hostname in `X-Forwarded-Host`.
|
||||
2. Do not infer a subdomain tenant from the API `Host`: `store1.example.com` and
|
||||
`example.com` intentionally share `api.example.com`. Non-browser clients must
|
||||
provide tenant context through their authenticated server-to-server contract.
|
||||
3. Resolve that normalized storefront hostname through the tenant-domain
|
||||
registry. Do not infer a tenant from only the first label.
|
||||
4. Reject unknown, disabled, or unverified domains with `403` before reading
|
||||
tenant data. Never fall back to the default/Dexar tenant.
|
||||
5. Bind the authenticated session to the resolved tenant and reject a mismatch.
|
||||
6. Trust `X-Storefront-Host` / `X-Forwarded-*` only from the known nginx proxy;
|
||||
direct clients can forge them.
|
||||
7. Return JSON for `/bootstrap`, including a tenant identity that matches the
|
||||
normalized storefront domain. HTML or a default tenant response is a fault.
|
||||
|
||||
Pseudo-code:
|
||||
|
||||
```text
|
||||
require request.remoteAddress is trustedProxy
|
||||
storefrontHost = lower(stripPort(request.header["X-Storefront-Host"]))
|
||||
tenant = registry.findVerifiedDomain(storefrontHost) ?? forbidden()
|
||||
request.tenant = tenant
|
||||
```
|
||||
|
||||
## 3. CORS contract
|
||||
|
||||
For API host `api.<base-domain>`, echo the exact validated storefront origin:
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Origin: https://<base-domain or registered subdomain>
|
||||
Access-Control-Allow-Credentials: true
|
||||
Vary: Origin
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type, AdminWebSessionID, X-Requested-With
|
||||
```
|
||||
|
||||
Answer valid preflight requests with `204`. Do not use `*` together with
|
||||
credentials. nginx applies this policy now; the backend should enforce the same
|
||||
allowlist when it is reached without that proxy.
|
||||
|
||||
## 4. nginx and TLS
|
||||
|
||||
Run the idempotent project script as root:
|
||||
|
||||
```bash
|
||||
scripts/deploy/configure-api-domain.sh \
|
||||
--domain gorbushka.market \
|
||||
--email ops@example.com \
|
||||
--upstream https://127.0.0.1:445
|
||||
```
|
||||
|
||||
It creates the shared `api.gorbushka.market`, issues/renews its certificate,
|
||||
configures CORS for `gorbushka.market` and its subdomains, and proxies all paths
|
||||
to the backend. A request from `store1.gorbushka.market` reaches upstream with
|
||||
`Host` and `X-Storefront-Host` set to `store1.gorbushka.market`, while
|
||||
`X-Forwarded-Host` remains `api.gorbushka.market`. nginx terminates CORS and
|
||||
strips the browser `Origin` before proxying because the current `:445` service
|
||||
rejects direct browser origins; tenant identity is carried by the trusted
|
||||
storefront header instead.
|
||||
|
||||
`store1.example.com` requires no additional API DNS or certificate; it uses the
|
||||
same `api.example.com` certificate as the root storefront.
|
||||
|
||||
## 5. CI/CD contract
|
||||
|
||||
`deploy.yml` runs the same root-owned configurator before activating a frontend
|
||||
release. Required production secrets:
|
||||
|
||||
| Secret | Example |
|
||||
|---|---|
|
||||
| `DEPLOY_HOST` | server hostname/IP |
|
||||
| `DEPLOY_USER` | `deploy` |
|
||||
| `DEPLOY_SSH_KEY` | private deploy key |
|
||||
| `DEPLOY_KNOWN_HOSTS` | pinned SSH host-key line |
|
||||
| `STOREFRONT_DOMAINS` | `gorbushka.market store1.example.com` |
|
||||
| `CERTBOT_EMAIL` | operations email |
|
||||
| `BACKEND_UPSTREAM` | `https://127.0.0.1:445` (optional default) |
|
||||
|
||||
One-time provisioning must first run `server-setup.sh`; it installs the helper
|
||||
as root-owned `/usr/local/sbin/marketplaces-configure-api-domain` and grants the
|
||||
deploy user permission to run only that validated command plus nginx reload.
|
||||
|
||||
## 6. Acceptance checks
|
||||
|
||||
For every storefront domain, all of these must pass:
|
||||
|
||||
```bash
|
||||
curl -fsS https://api.example.com/bootstrap | jq -e 'type == "object"'
|
||||
curl -i -X OPTIONS https://api.example.com/bootstrap \
|
||||
-H 'Origin: https://example.com' \
|
||||
-H 'Access-Control-Request-Method: GET'
|
||||
```
|
||||
|
||||
- Frontend bundle contains no fixed marketplace API hostname.
|
||||
- Root and nested storefronts call the same `api.<base-domain>`.
|
||||
- Unknown storefront domains return `403`, not the default tenant.
|
||||
- `/bootstrap` returns JSON and the correct tenant.
|
||||
- API responses never return the Angular `index.html` fallback.
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,128 +0,0 @@
|
||||
# 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.
|
||||
|
||||
### 4.1 Partner credentials (inbound)
|
||||
|
||||
Added 2026-08-18. Partners calling our API authenticate with signed requests, not bearer tokens. Full contract: [PARTNER-PROVISIONING-API-CONTRACT.md §6](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
These are the opposite direction from the rest of §4 and follow a different rule:
|
||||
|
||||
- We hold only the partner's **public** key. The private key is generated by the partner and never transmitted to us, never accepted by any endpoint, never logged. There is nothing to store in secret storage on our side.
|
||||
- Authority is node-scoped: a credential may act on its `scopeNodeId` and that node's descendants, nothing above or beside it. This is a separate axis from the 17 roles in §1 — partner credentials never map onto a human role, and a partner credential can never be granted an admin role.
|
||||
- `TEST` and `LIVE` credentials are disjoint. A `TEST` key addressing a `LIVE` node is `403`.
|
||||
- Rotation runs with a bounded overlap window (default 7 days) during which both keys verify. Revocation is immediate and irreversible.
|
||||
- A credential can never widen its own scope or register another credential at a wider scope.
|
||||
|
||||
Audit coverage (§3) extends to: `partner_credential.registered`, `partner_credential.rotated`, `partner_credential.revoked`, and every partner-initiated node write, with `actor` set to the `keyId` that signed the request.
|
||||
|
||||
## 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.
|
||||
|
||||
Partner API limits are per `partnerId`, by tier, with the tier set on `PartnerProfile`. Published in the partner OpenAPI spec — a partner must be able to read its own limit rather than discover it by getting `429`.
|
||||
|
||||
## 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` = cryptographically random one-time secret delivered out of band,
|
||||
flagged `mustChangePassword: true` (never derive it from the marketplace slug)
|
||||
- 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.
|
||||
Reference in New Issue
Block a user