docs(backend): consolidate all backend contracts into one file
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Collapses the entire docs/backend/ set - Phase 1-10, Track A/S, the partner API, the two handoffs, the frontend surface inventory, and the harvest requirements - into a single source of truth, docs/backend/BACKEND-INTEGRATION.md. Every contract's entities, endpoints, and invariants are preserved, reorganised by domain rather than by sprint. The nine release invariants, the FH-* harvest mechanisms, the RBAC/audit/secrets cross-cutting rules, the tenant-routing infra contract, the 15 acceptance tests, build order, dev setup, and open decisions are all in the one file, with a change log (§14) at the bottom. The file opens with the maintenance rule: any new backend need, contract change, or shipped item updates this file in the same change - the affected section and the change log. No new backend .md files. Inbound links from BACKEND-API-REFERENCE, the ADRs, the fork docs, DEPLOYMENT, PACKAGES-USAGE, the delivery plan, and e2e/README are repointed at the single doc (section anchors collapse to the file; the prose section refs remain as context). Also recorded the rule in the repo CLAUDE.md. 17 backend docs removed, 1 added. No implementation changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ separate developer's responsibility. API hostnames are separate reverse proxies
|
||||
and return `502` until their configured upstream exists (production currently
|
||||
defaults to `https://127.0.0.1:445`).
|
||||
|
||||
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-HANDOFF.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
||||
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-INTEGRATION.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
||||
|
||||
The SPA derives one API origin from the storefront's base domain:
|
||||
`example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
@@ -148,7 +148,7 @@ sudo bash setup-wildcard-tls.sh \
|
||||
|
||||
Wildcards require DNS-01 validation, so certbot must write a `_acme-challenge` TXT record. With a provider plugin (`cloudflare`, `route53`) renewal is unattended. `--dns manual` works but prompts for a TXT record at **every** renewal — fine to prove the setup out, not acceptable as a steady state.
|
||||
|
||||
**Hostinger has no certbot plugin.** If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the [Phase 9](backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) domain-automation API once it exists.
|
||||
**Hostinger has no certbot plugin.** If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the [Phase 9](backend/BACKEND-INTEGRATION.md) domain-automation API once it exists.
|
||||
|
||||
### 4.2 Reconciler — tenants on their own domains
|
||||
|
||||
|
||||
@@ -189,11 +189,11 @@ That list is our leverage. It is also a precise statement of which of our module
|
||||
|
||||
### P0 — take these regardless of how the org question resolves
|
||||
|
||||
1. **Conditional-UPDATE stock reservation.** Adopt the pattern verbatim in `docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md`: reserve via `WHERE (onHand - reserved) >= qty RETURNING id`, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
|
||||
2. **Idempotency as a unique constraint, not application logic.** `Payment.idempotencyKey UNIQUE`, `PaymentWebhookEvent @@unique([provider, eventKey])`, P2002 → `{duplicate: true}`. Push this into `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md` as a schema requirement, not a behavioural note.
|
||||
1. **Conditional-UPDATE stock reservation.** Adopt the pattern verbatim in `backend/BACKEND-INTEGRATION.md`: reserve via `WHERE (onHand - reserved) >= qty RETURNING id`, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
|
||||
2. **Idempotency as a unique constraint, not application logic.** `Payment.idempotencyKey UNIQUE`, `PaymentWebhookEvent @@unique([provider, eventKey])`, P2002 → `{duplicate: true}`. Push this into `backend/BACKEND-INTEGRATION.md` as a schema requirement, not a behavioural note.
|
||||
3. **Move every credential out of the browser.** Their audit is right about `authorization-key` / `userid-value` / partner ID. Mirror `PaymentCredential.encryptedConfig` (AES-256-GCM, versioned `v1.iv.tag.ct`) in our contract and delete the client-side header path.
|
||||
4. **HttpOnly server sessions, separate cookie per contour** (`bo_session`, `manager_session`, `marketplace_session`). Kill the JS-set cookie and the shared Telegram session for admin + customer. This is our single worst finding in their audit.
|
||||
5. **Origin allowlist hook for all admin mutations.** Twelve lines in `main.ts`; kills CSRF for cookie-authenticated mutations. Mirror in `TRACK-S-SECURITY-RBAC-CONTRACT.md`.
|
||||
5. **Origin allowlist hook for all admin mutations.** Twelve lines in `main.ts`; kills CSRF for cookie-authenticated mutations. Mirror in `backend/BACKEND-INTEGRATION.md`.
|
||||
6. **Backend-side config validation.** Our validation engine is better than theirs, but it runs in the browser. The server must re-run it. Their clamp-and-fallback ergonomics are right: never reject a colour, clamp it; never accept a non-https URL, blank it.
|
||||
|
||||
### P1 — take into our own architecture
|
||||
@@ -250,7 +250,7 @@ So there is nothing to copy from them here. But their **session-issuing half is
|
||||
| [vk-id-gateway.token.ts](src/app/core/identity/services/vk-id-gateway.token.ts) | DI seam via `environment.useMockData` |
|
||||
| [vk-id-login.component.ts](src/app/components/vk-id-login/vk-id-login.component.ts) | Button component |
|
||||
| [customer-identity.model.ts](src/app/core/identity/models/customer-identity.model.ts) | `ExternalIdentityProvider = 'vk_id' \| 'telegram' \| 'max'` |
|
||||
| [PHASE-8-IDENTITY-MESSAGING-CONTRACT.md](docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | §2 defines the VK ID contract |
|
||||
| [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md) | §2 defines the VK ID contract |
|
||||
|
||||
We have the scaffolding and the contract. Yandex is absent everywhere except one mention of *Yandex Market* as a possible marketplace connector in the gap analysis — a different thing entirely.
|
||||
|
||||
|
||||
@@ -49,29 +49,29 @@ Improvements only. Nothing here regresses our Angular version, test count, or ar
|
||||
|
||||
## Wave 2 — Contract hardening (Lane B, parallel with Wave 1)
|
||||
|
||||
Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND-HANDOFF.md`, so it becomes a delivery gate rather than a wish.
|
||||
Each item is normative text plus an acceptance scenario in `backend/BACKEND-INTEGRATION.md`, so it becomes a delivery gate rather than a wish.
|
||||
|
||||
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md`
|
||||
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL.
|
||||
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
|
||||
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
|
||||
|
||||
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md`
|
||||
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation.
|
||||
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
|
||||
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
|
||||
|
||||
- [x] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
- [x] **FH-2.3 — Session and credential model** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction.
|
||||
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope.
|
||||
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
|
||||
|
||||
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS.
|
||||
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
|
||||
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
|
||||
|
||||
- [x] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md`
|
||||
- [x] **FH-2.5 — Tenant by verified Host only** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`.
|
||||
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
|
||||
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
|
||||
@@ -86,7 +86,7 @@ Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
|
||||
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
|
||||
|
||||
- [x] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md`
|
||||
- [x] **FH-2.8 — Append-only inventory journal** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time.
|
||||
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
|
||||
**Acceptance:** any current quantity is reconstructible from the journal alone.
|
||||
@@ -96,7 +96,7 @@ Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND
|
||||
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
|
||||
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
|
||||
|
||||
- [x] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md`
|
||||
- [x] **FH-2.10 — Server-side storefront config validation** · M · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers.
|
||||
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
|
||||
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
|
||||
@@ -109,7 +109,7 @@ Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND
|
||||
- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better**
|
||||
Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again.
|
||||
|
||||
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md`
|
||||
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `backend/BACKEND-INTEGRATION.md`
|
||||
**Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place.
|
||||
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
|
||||
|
||||
@@ -184,7 +184,7 @@ The **client half and the contract are done** (2026-08-21). What remains is back
|
||||
- [x] **FH-4.2 — Move PKCE ownership to the backend** · S · **done 2026-08-21**
|
||||
`completeCallback()` is gone from the frontend entirely. PHASE-8 §2.1–2.2 rewritten: `/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 minutes, `/callback` is a backend GET that exchanges, links, issues the session cookie and redirects. `returnTo` validated against the tenant's own origin.
|
||||
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
|
||||
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `PHASE-8-IDENTITY-MESSAGING-CONTRACT.md` §2.
|
||||
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `backend/BACKEND-INTEGRATION.md` §2.
|
||||
|
||||
- [~] **FH-4.3 — `ExternalIdentity` model** · S · Lane B · **contract written 2026-08-21, awaiting backend**
|
||||
PHASE-8 §1 and §2.3: `UNIQUE (provider, providerUserId)`, conflict routes to controlled resolution rather than rebinding, optional email/phone/displayName, per-tenant OAuth app config under the Track S §4.2 envelope.
|
||||
@@ -236,13 +236,13 @@ The **client half and the contract are done** (2026-08-21). What remains is back
|
||||
## Continuous — Process (Lane E)
|
||||
|
||||
- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21**
|
||||
Now `BACKEND-HANDOFF.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
|
||||
Now `backend/BACKEND-INTEGRATION.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
|
||||
|
||||
- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21**
|
||||
`BACKEND-HANDOFF.md` §0a, with the expand/contract migration rule alongside it.
|
||||
`backend/BACKEND-INTEGRATION.md` §0a, with the expand/contract migration rule alongside it.
|
||||
|
||||
- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21**
|
||||
`BACKEND-HANDOFF.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
|
||||
`backend/BACKEND-INTEGRATION.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
|
||||
|
||||
- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21**
|
||||
[ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle.
|
||||
|
||||
@@ -83,7 +83,7 @@ private readonly auth = inject(AuthService);
|
||||
readonly isLoggedIn = this.auth.isAuthenticated; // signal
|
||||
```
|
||||
|
||||
**Security note:** the Telegram session API has no concept of "admin." The frontend cannot distinguish an admin Telegram session from a regular one — it only decides *where to store* the result. Real admin authorization must be enforced server-side on every admin request. See [TRACK-S](backend/TRACK-S-SECURITY-RBAC-CONTRACT.md).
|
||||
**Security note:** the Telegram session API has no concept of "admin." The frontend cannot distinguish an admin Telegram session from a regular one — it only decides *where to store* the result. Real admin authorization must be enforced server-side on every admin request. See [TRACK-S](backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### `ed25519/` — prepared, backend not shipped
|
||||
|
||||
@@ -112,7 +112,7 @@ Bind the verification seam in `app.config.ts`:
|
||||
|
||||
## 5. `@marketplaces/payment`
|
||||
|
||||
Published at `0.1.0` but **scaffold only** — no implementation yet, nothing exported, and `marketplaces` does not depend on it. `core/finance` and `core/pricing` still live in the app. Payment business logic is server-side by design (see [Phase 1](backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) and [Phase 7](backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
|
||||
Published at `0.1.0` but **scaffold only** — no implementation yet, nothing exported, and `marketplaces` does not depend on it. `core/finance` and `core/pricing` still live in the app. Payment business logic is server-side by design (see [Phase 1](backend/BACKEND-INTEGRATION.md) and [Phase 7](backend/BACKEND-INTEGRATION.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
|
||||
|
||||
## 6. Making a change to a package
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Nothing downstream can be honestly estimated until this closes. Two sprints: one
|
||||
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
|
||||
- [x] **Multi-seller orders — unified**, judgment call as instructed. One `Order` per checkout regardless of seller count, split into per-seller `Fulfillment` groups internally (matches §2.8's "canonical Order regardless of source" and §2.5's cart-level seller-grouping requirement without introducing parallel parent orders). Applies to Phase 3's `Offer` model, Phase 5's Seller Portal order view (scoped to that seller's fulfillment groups within the shared order), and closes the three-document disagreement flagged in Z16.
|
||||
- [x] **"Fixed 5-second payment" claim — resolved as a non-issue.** User: "make polling 5 secs." Checked `config/constants.ts`: `PAYMENT_POLL_INTERVAL_MS` is already `5000`. This is a poll *cadence* against real provider status each tick, not an artificial fixed-delay-then-success — stays compliant with the plan's §3.2 prohibition. No code change needed; confirmed and left as-is.
|
||||
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
|
||||
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `backend/BACKEND-INTEGRATION.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
|
||||
- [x] **Document version — v3.1 is canonical.** The source file's internal "3.0" version block is stale/wrong; all our docs treat v3.1 as authoritative going forward.
|
||||
|
||||
**Exit:** all nine answered in writing.
|
||||
@@ -397,16 +397,16 @@ Only after Commerce Core is real. The plan is explicit that Gorbushka does not d
|
||||
|
||||
### Track P — Partner provisioning API (added 2026-08-18)
|
||||
|
||||
Inbound partner API for programmatic merchant-hierarchy management. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](backend/PARTNER-PROVISIONING-API-CONTRACT.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
||||
Inbound partner API for programmatic merchant-hierarchy management. Contract: [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
||||
|
||||
**P1–P3 gate Phase 1.** They change the payments and tenant schemas, so they must land before Phase 1 is implemented — retrofitting a routing dimension onto a populated payments table costs far more than carrying it from the first row. P4 onward can run any time after.
|
||||
|
||||
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md)). Frozen at checkout-session creation, immutable after. **M**
|
||||
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). **M**
|
||||
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md). **M**
|
||||
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/BACKEND-INTEGRATION.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/BACKEND-INTEGRATION.md)). Frozen at checkout-session creation, immutable after. **M**
|
||||
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/BACKEND-INTEGRATION.md)). **M**
|
||||
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/BACKEND-INTEGRATION.md). **M**
|
||||
- [ ] **P4** Provisioning endpoints: create/read/status/disable for project, store, payment point, with cascading disable. **L**
|
||||
- [ ] **P5** Idempotency-Key handling: replay on identical body, `409` on same key + different body, in-flight collision, 24h retention. **M**
|
||||
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/TRACK-S-SECURITY-RBAC-CONTRACT.md)). **L**
|
||||
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/BACKEND-INTEGRATION.md)). **L**
|
||||
- [ ] **P7** Read surfaces: full-hierarchy fetch, `externalReference` lookup, partner-scoped audit query. **M**
|
||||
- [ ] **P8** `PartnerProfile` config: required levels, level aliases, routing field names, rate tier, rotation window. Onboarding a partner must be a config row, not a deployment. **M**
|
||||
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Backend handoff — start here
|
||||
|
||||
Single entry point for a backend developer picking this up cold. Written 2026-08-18.
|
||||
|
||||
## 0. Invariants — the acceptance gate
|
||||
|
||||
Added 2026-08-21 (FH-E.1). Nine statements. Each is falsifiable, each has a test, and a release that violates any of them does not ship regardless of what else is finished. They come before the contracts because a contract can be read selectively and these cannot.
|
||||
|
||||
1. The 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 arriving in a request is ignored, never validated-and-used.
|
||||
3. Stock and reservation change atomically. Two buyers racing for the last unit produce one payable order.
|
||||
4. Payment creation and webhook receipt are idempotent, enforced by unique constraints rather than by 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 through a direct API call.
|
||||
9. Every administrative mutation leaves an audit record naming actor, action, before and after.
|
||||
|
||||
Where each is specified: 1 and 6–7 in [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §5–6; 2 in [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5; 3 in [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) §3.1; 4 in [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §5; 5, 8 and 9 in [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) §2, §3, §4.2.
|
||||
|
||||
## 0a. How work lands
|
||||
|
||||
Added 2026-08-21 (FH-E.2, FH-E.3).
|
||||
|
||||
**One functional area per pull request.** A PR carries: what it does, screenshots where there is UI, API changes, migrations, test evidence, security impact, and a rollback plan. Never change a payment, inventory, or order state machine in the same PR as a redesign — those two things fail differently and must be revertable separately.
|
||||
|
||||
**Migrations are expand/contract.** A migration and the frontend change that depends on it belong to one release train, but they may be separate PRs, and the expand step must be deployable on its own.
|
||||
|
||||
**A release is not "the build passed."** Each one records: version, migrations applied, healthcheck result, post-deploy smoke result, production dependency audit, and the rollback path actually available. A local build succeeding and a UI existing are not evidence of production readiness — the deploy and the smoke check are.
|
||||
|
||||
## 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.
|
||||
|
||||
Contract sections dated 2026-08-21 and tagged `FH-*` come from [FORK-ANALYSIS-2026-08-21.md](../FORK-ANALYSIS-2026-08-21.md) — a review of a parallel platform implementation that has a working backend. They are mechanisms that implementation already proved, harvested deliberately; the tag is there so you can trace any one of them back to why it is worded the way it is.
|
||||
|
||||
## 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, password = `{slug}2026$`, `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.
|
||||
482
docs/backend/BACKEND-INTEGRATION.md
Normal file
482
docs/backend/BACKEND-INTEGRATION.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# 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/`.
|
||||
|
||||
### 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. 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,134 +0,0 @@
|
||||
# Harvest — Consolidated Backend Requirements
|
||||
|
||||
**Date:** 2026-08-22
|
||||
**Branch:** `improvements/fork-harvest`
|
||||
**Source:** [FORK-ANALYSIS-2026-08-21.md](../FORK-ANALYSIS-2026-08-21.md) · decision [ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)
|
||||
|
||||
One place to see everything the backend must build from the fork harvest. Each item is a *mechanism* — a specific rule, schema constraint, or endpoint behaviour — lifted from a parallel platform implementation that already runs it in production, then written into our phase contracts. This document is the index and the acceptance gate; the phase contracts hold the full entity shapes.
|
||||
|
||||
Read it alongside [BACKEND-HANDOFF.md](BACKEND-HANDOFF.md). Where the two overlap, the handoff wins; this file adds the harvested mechanisms and their acceptance tests.
|
||||
|
||||
---
|
||||
|
||||
## 1. The nine invariants (release gate)
|
||||
|
||||
A release that violates any one of these does not ship, regardless of what else is finished. Each is falsifiable and has a test named in §3.
|
||||
|
||||
1. The 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 arriving 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 rather than 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 through a direct API call.
|
||||
9. Every administrative mutation leaves an audit record naming actor, action, before, and after.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements by area
|
||||
|
||||
Effort is the backend build cost. Every item is already written in its phase contract; the section reference is where the full detail lives.
|
||||
|
||||
### 2.1 Money, stock, payments — the correctness core
|
||||
|
||||
| ID | Requirement | Contract | Effort |
|
||||
|---|---|---|---|
|
||||
| FH-2.1 | **Atomic reservation.** Reserve with one conditional write: `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 rows. No `SELECT` before the `UPDATE`, no advisory lock. TTL 15 min. | [PHASE-3 §3.1](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | S |
|
||||
| FH-2.8 | **Append-only inventory journal.** Every change to available/reserved/sold writes one immutable `InventoryMovement` with `reason`, `referenceType`, `referenceId`, `actor`, and `resultingAvailable` recorded at the time. Corrections are new compensating rows. Replaying the journal must reproduce the current record exactly. | [PHASE-3 §3.2](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | S |
|
||||
| FH-2.2 | **Idempotency as constraints.** `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 → `409`. Webhook: insert the event row first, a unique-violation is the duplicate signal (`{accepted:true, duplicate:true}`), only a successful insert applies the status change, `processedAt` set after applying. `event_key` = provider event id, else `sha256(rawBody)`. Signature verified against the **raw** body before parsing. | [PHASE-7 §5](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) | S |
|
||||
| FH-2.9 | **Encrypted secret envelope.** `v1.<iv>.<authTag>.<ciphertext>` base64url, AES-256-GCM, 12-byte IV per value, 32-byte key from env/secret-manager. Decrypt only in the using service; never on a DTO, in a log, or in any response — including to a `PLATFORM_OWNER`. Display uses `HMAC-SHA256(key, value)` fingerprints. Redirect/callback URLs built backend-side and allowlisted. | [TRACK-S §4.2](TRACK-S-SECURITY-RBAC-CONTRACT.md) | S |
|
||||
| FH-2.11 | **Digital code pools.** `FulfillmentMode: manual | code_pool`. `DigitalCode` with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, offer)`. `available` for a `code_pool` offer derives from the count of available codes. Codes move `available→reserved` under the FH-2.1 conditional 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. | [PHASE-3 §6a](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | M |
|
||||
|
||||
### 2.2 Sessions, access, secrets
|
||||
|
||||
| ID | Requirement | Contract | Effort |
|
||||
|---|---|---|---|
|
||||
| FH-2.3 | **Session model.** Token 32 random bytes, stored as **SHA-256 hash only**. `HttpOnly; Secure; SameSite`, revocable, `expiresAt`/`revokedAt`/`ip`/`userAgent`. One cookie name per contour (`bo_session`/`manager_session`/`marketplace_session`) — a customer session never satisfies an admin guard. Argon2id `memoryCost 65536, timeCost 3, parallelism 1`, ≥16 chars. TOTP mandatory for platform/marketplace roles, gated by a signed single-use 10-min enrolment token that grants nothing else. Password change revokes every live session in the same transaction. | [TRACK-S §2.1](TRACK-S-SECURITY-RBAC-CONTRACT.md) | M |
|
||||
| FH-2.4 | **Origin allowlist.** One hook ahead of routing: any non-`GET`/`HEAD`/`OPTIONS` on `/api/admin/*`, `/api/platform/*`, `/api/manager/*` whose `Origin` is not allowlisted → `403`. CORS uses the same allowlist with `credentials:true` — not `*`, not reflected. Per-environment configuration. | [TRACK-S §2.2](TRACK-S-SECURITY-RBAC-CONTRACT.md) | S |
|
||||
| FH-2.14 | **Order-manager as a separate contour.** Own URL, shell, login, and session cookie; an order 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. | [TRACK-S §8a](TRACK-S-SECURITY-RBAC-CONTRACT.md) | M |
|
||||
|
||||
### 2.3 Tenancy, publish, content
|
||||
|
||||
| ID | Requirement | Contract | Effort |
|
||||
|---|---|---|---|
|
||||
| FH-2.5 | **Tenant by verified Host only.** Normalize (lowercase, strip trailing dot, strip port) then match a unique `hostname` row; resolve only when `verifiedAt` is set and the marketplace serves. Brief cache with **explicit invalidation** on domain add/verify/remove and state change. `Host` read from the trusted proxy chain (proxy overwrites client value). No public endpoint accepts `marketplaceId`. Unknown host → `404`, no fallback tenant. | [PHASE-9 §6](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) | S |
|
||||
| FH-2.6 | **Signed read-only preview.** HMAC over `{marketplaceId, expiresAt, nonce}`, 15-min TTL, `storefront_preview` HttpOnly cookie, constant-time comparison, invalid/expired → `404`. While the preview cookie is present, every non-`GET` on the public API returns `404` (hook ahead of routing). Preview responses carry `X-Robots-Tag: noindex, nofollow`. | [PHASE-9 §5.3](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) | S |
|
||||
| FH-2.7 | **Immutable revisions + clone.** `version = max(version)+1`, `UNIQUE(marketplaceId, version)`, materialized snapshot, `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 carries design + catalog assignments, forces inventory to zero, never carries domains/customers/orders/secrets; category walk is topological with cycle detection (`400` on a cycle). | [PHASE-9 §5.1–5.2](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) | M |
|
||||
| FH-2.10 | **Server-side content validation.** 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 is not 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. | [PHASE-10 §3a](PHASE-10-CONTENT-MODULES-CONTRACT.md) | M |
|
||||
| FH-2.15 | **Bulk import idempotency + rollback.** Idempotent by SKU/external key (re-run updates, never duplicates). A row-level error never publishes a partial result. An applied import is rollback-able only while none of its products have appeared on a paid order; after that, archive. (Validate-then-apply preview already specified in [PHASE-3 §6](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md).) | [PHASE-3 §6](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) | S |
|
||||
| FH-2.13 | **Order public token + snapshot completeness.** `Order.publicToken` ≥24 random bytes, base64url, unique; every customer-facing route addresses an order by it, never by `id`. Tenant-scoped lookup; 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. | [PHASE-2 §3.1](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) | S |
|
||||
|
||||
### 2.4 Identity — VK ID, Yandex ID, Telegram migration
|
||||
|
||||
The frontend is **built and waiting**: provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen all exist and are tested. What is left is backend implementation plus the FH-0.1 decision below.
|
||||
|
||||
| ID | Requirement | Contract | Effort |
|
||||
|---|---|---|---|
|
||||
| FH-4.2 | **Backend owns PKCE.** `GET /{provider}/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 min, returns/302s to the provider with `code_challenge` (S256). `GET /{provider}/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. | [PHASE-8 §2.1–2.2](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | M |
|
||||
| FH-4.3 | **`ExternalIdentity`.** `UNIQUE(provider, providerUserId)`. A provider account already bound to a *different* customer is an identity conflict routed to controlled resolution — never a silent rebind; the unique index enforces it. Email/phone/displayName optional. Per-tenant OAuth app config stored under the FH-2.9 envelope. | [PHASE-8 §2.3–2.4](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | S |
|
||||
| FH-4.4 | **VK ID.** 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. VK frequently returns no email; keep it optional. | [PHASE-8 §2.5](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | M |
|
||||
| FH-4.5 | **Yandex ID.** 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 against the same surface; build after VK works. | [PHASE-8 §2.5](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | S |
|
||||
| FH-4.6 | **Telegram → `ExternalIdentity`.** A Telegram login writes an `ExternalIdentity` (`provider:'telegram'`) under the same uniqueness/conflict rule; it appears in `/me/identities` and is unlinkable subject to the last-identity `409`. 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. | [PHASE-8 §2.6](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | M |
|
||||
| FH-4.8 | **Email/phone OTP as recovery.** 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. | [PHASE-8 §3](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) | S |
|
||||
|
||||
**Blocking decision — FH-0.1, needed before any identity code.** VK and Yandex both validate `redirect_uri` against an exact registered list, so a multi-tenant platform cannot register one per tenant domain. Resolution to confirm: one **central identity host** as the sole registered callback, tenant carried inside the signed `state`, a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for its session cookie. Also decide: **one VK account across two storefronts — one `Customer` or two?** `Customer.marketplaceId` implies two, which is the safer default. Record both in an ADR.
|
||||
|
||||
### 2.5 Operations
|
||||
|
||||
| ID | Requirement | Effort |
|
||||
|---|---|---|
|
||||
| FH-D.1 | **Proven restore drill.** 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. A backup nobody has restored is a hope, not a backup. | M |
|
||||
| FH-D.2 | **Database unreachable from the internet, structurally.** Data network `internal: true`, API bound to loopback, `no-new-privileges` on every service — a property of the topology, not a firewall promise. | S |
|
||||
|
||||
Host hardening (FH-D.3: sshd, fail2ban, sysctl) is **done** on the frontend deploy — see [DEPLOYMENT.md](../DEPLOYMENT.md) §3.2. The two above are the backend/compose half.
|
||||
|
||||
---
|
||||
|
||||
## 3. Acceptance tests
|
||||
|
||||
These are the falsifiable checks behind the invariants. They are backend integration tests — the frontend cannot prove a race or a replay against a mock.
|
||||
|
||||
| # | Scenario | Passes when | Guards |
|
||||
|---|---|---|---|
|
||||
| A1 | Two concurrent checkouts for the last unit | Exactly one payable order, one clean `409` | Inv. 3, FH-2.1 |
|
||||
| A2 | The same provider webhook event delivered twice | Order completes once, stock moves once, one notification | Inv. 4, FH-2.2 |
|
||||
| A3 | A price sent in a checkout request | Ignored; charged amount is the server's | Inv. 2 |
|
||||
| A4 | Unknown or unverified `Host` | `404`, no other tenant's data | Inv. 1, FH-2.5 |
|
||||
| A5 | `MARKETPLACE_ADMIN` for A queries B directly | `403`, not an empty result | Inv. 8, FH-2.3 |
|
||||
| A6 | Cross-origin POST with a valid session cookie | Refused | FH-2.4 |
|
||||
| A7 | Any credential value searched for in responses, logs, bundle | Absent | Inv. 5, FH-2.9 |
|
||||
| A8 | Rollback a design revision | Chosen revision restored, live inventory untouched | Inv. 6–7, FH-2.7 |
|
||||
| A9 | Mutation attempted while a preview cookie is present | `404` | FH-2.6 |
|
||||
| A10 | Hand-crafted API call storing a config the editor would reject | Refused | FH-2.10 |
|
||||
| A11 | Unpaid order requests its digital code | Empty code list | FH-2.11 |
|
||||
| A12 | Re-run the same import file | Updates, does not duplicate | FH-2.15 |
|
||||
| A13 | Second VK login for the same `providerUserId` | Same `Customer`, no duplicate | FH-4.3 |
|
||||
| A14 | VK account already bound to a different customer | Conflict resolution, no silent rebind | FH-4.3 |
|
||||
| A15 | Unlink a customer's only remaining identity | `409` | FH-4.6 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Build order
|
||||
|
||||
Nothing here reorders the phase build order; it sharpens what "done" means inside each phase.
|
||||
|
||||
1. **Launch-gate (P0):** the correctness core and access model — FH-2.1, FH-2.8, FH-2.2, FH-2.9, FH-2.3, FH-2.4, FH-2.5. Nothing ships without these; they are invariants 1–5 and 8.
|
||||
2. **Publish & content:** FH-2.6, FH-2.7, FH-2.10, FH-2.13, FH-2.15 (invariants 6–7).
|
||||
3. **Identity:** unblock FH-0.1, then FH-4.2 → FH-4.3 → FH-4.4 → FH-4.5 → FH-4.6 → FH-4.8. The frontend for this is already built.
|
||||
4. **Digital goods & manager contour:** FH-2.11, FH-2.14 — after the core, before the tenants that need them.
|
||||
5. **Operations, continuous:** FH-D.1, FH-D.2 alongside everything.
|
||||
|
||||
Audit coverage (invariant 9) is not a step — it is a property every mutating endpoint carries from its first line ([TRACK-S §3](TRACK-S-SECURITY-RBAC-CONTRACT.md)).
|
||||
|
||||
---
|
||||
|
||||
## 5. What the frontend already did
|
||||
|
||||
So the backend knows what it is building *to*, not guessing:
|
||||
|
||||
- **Geo** resolves through `{tenantApiBase}/geo/resolve` (server reads client IP) — the endpoint needs building; see [BACKEND-API-REFERENCE.md](../../BACKEND-API-REFERENCE.md) §6.
|
||||
- **Payment credentials** are gone from the browser; a CI scan (`scripts/ci/scan-bundle.sh`) fails the build if any return, and also if a mock gateway or its fixtures reach the bundle.
|
||||
- **Identity**: the provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen (`/me/identities`, link/unlink, last-identity guard, conflict slot) are built and tested — waiting on §2.4 endpoints and FH-0.1.
|
||||
- **Bundle** is 1.04 MB (from 1.55 MB) with the JIT compiler removed; a blocking budget guards it.
|
||||
- The **nine invariants** and the PR/release discipline are recorded in [BACKEND-HANDOFF.md](BACKEND-HANDOFF.md) §0–0a.
|
||||
@@ -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,138 +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.
|
||||
|
||||
## 3a. The server re-validates everything the editor validates
|
||||
|
||||
Added 2026-08-21 (FH-2.10). Our storefront editor has the stronger validation engine of the two implementations reviewed — blockers, warnings and informational notices, evaluated before publish. It all runs in the browser, which means it constrains the editor UI and nothing else. Anyone with a session and `curl` bypasses the entire thing.
|
||||
|
||||
The backend re-runs the same rules on write. It is the only copy that binds.
|
||||
|
||||
Ergonomics matter here, because a validator that hard-fails on cosmetic input makes the editor unusable. Follow clamp-and-fallback:
|
||||
|
||||
- Numbers outside their allowed range are **clamped** to the range, not rejected.
|
||||
- A colour that is not a valid hex value falls back to the documented default.
|
||||
- A URL is accepted only if it is a same-origin path (`/…`, not `//…`, no backslashes) or `https://`. Anything else is stored as empty, not stored as given.
|
||||
- Free text is trimmed and truncated at its documented maximum.
|
||||
- Structural violations — an unknown block type, a malformed id, more blocks than the page allows, more referenced entity ids than the list allows — are a `400`. These cannot be silently coerced into something meaningful.
|
||||
|
||||
Hard limits belong in this contract rather than in the client: maximum blocks per page, maximum referenced ids per block, maximum length per text field. Publish it as one schema and let both sides read it, so the editor and the server cannot drift.
|
||||
|
||||
Referential checks run at publish, not on every keystroke: a block pointing at a deleted category or an unpublished offer is a publish **blocker** unless the block declares a fallback. This is the same rule as [Phase 3 §5](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) publish-time executability, applied to content instead of offers.
|
||||
|
||||
**Acceptance:** a hand-crafted API call cannot store a configuration the editor would have refused.
|
||||
|
||||
## 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,161 +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
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 Public addressing and snapshot completeness
|
||||
|
||||
Added 2026-08-21 (FH-2.13).
|
||||
|
||||
- `Order` carries a `publicToken`: at least 24 random bytes, base64url, unique. **Every customer-facing route addresses an order by this token, never by `id`.** Order confirmation links, status polling, and support lookups all use it. A sequential or guessable public identifier turns "check my order" into an enumeration of the tenant's order book.
|
||||
- `GET /api/v2/storefront/orders/{publicToken}` is scoped to the resolved tenant. A valid token from a different marketplace is `404`.
|
||||
- `OrderLine` already snapshots SKU, title and price. Extend that to **everything that must survive a later edit**: currency, per-line discount, delivery option and price, and the tax/fee components. Together with `OrderContactSnapshot`, an order must be fully reconstructable from its own rows — renaming an offer, changing a price, or deleting a delivery option must not alter what a historical order says was bought and charged.
|
||||
- Snapshot fields are written once at order creation and never updated. A correction is a new order event, a refund, or an amendment record — never an in-place rewrite of what the customer agreed to.
|
||||
|
||||
## 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,225 +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.
|
||||
|
||||
### 3.1 Reservation must be atomic — the mechanism, not just the intent
|
||||
|
||||
Added 2026-08-21 (FH-2.1). The invariants above say reservations exist; they do not say how two buyers racing for the last unit are separated. Specify the mechanism, because "check availability, then reserve" is a read-then-write race and will oversell under load no matter how the code above it is written.
|
||||
|
||||
Reserve with a single conditional write that both tests and updates:
|
||||
|
||||
```sql
|
||||
UPDATE inventory
|
||||
SET reserved = reserved + :qty
|
||||
WHERE offer_id = :offerId
|
||||
AND (available - reserved) >= :qty
|
||||
RETURNING id
|
||||
```
|
||||
|
||||
- **Zero rows returned means insufficient stock.** Respond `409` with the offending offer, do not retry, do not partially reserve. A multi-line cart reserves every line inside one transaction; any line returning zero rows rolls back all of them.
|
||||
- No `SELECT` before the `UPDATE`. No advisory lock. No application-level retry loop. The `WHERE` clause is the concurrency control.
|
||||
- Reservation TTL is 15 minutes from checkout-session creation. Expiry releases `reserved` back to `available` and writes a journal row (§3.2).
|
||||
- The same rule governs release and consumption: one conditional statement, never read-modify-write.
|
||||
|
||||
**Acceptance:** two concurrent checkouts for the last unit produce exactly one payable order and one clean `409`. This is scenario 3 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test, not a code-review item.
|
||||
|
||||
### 3.2 Inventory movements are an append-only journal
|
||||
|
||||
Added 2026-08-21 (FH-2.8). Every change to `available`/`reserved`/`sold` writes one immutable row:
|
||||
|
||||
```ts
|
||||
interface InventoryMovement {
|
||||
id: string;
|
||||
offerId: string;
|
||||
deltaAvailable: number;
|
||||
deltaReserved: number;
|
||||
deltaSold: number;
|
||||
reason: 'checkout_reservation' | 'reservation_expired' | 'reservation_released'
|
||||
| 'payment_confirmed' | 'manual_adjustment' | 'feed_sync' | 'connector_sync'
|
||||
| 'refund_restock' | 'oversell_correction';
|
||||
referenceType?: 'reservation' | 'order' | 'import' | 'connector';
|
||||
referenceId?: string;
|
||||
actor?: string; // user id for manual adjustments, null for system
|
||||
resultingAvailable: number; // balance after this movement, not recomputed later
|
||||
occurredAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Rows are never updated or deleted. A correction is a new compensating row.
|
||||
- `resultingAvailable` is written at the time of the movement. Replaying the journal must reproduce the current record exactly; a divergence is a defect to investigate, not a number to overwrite.
|
||||
- A manual adjustment without an `actor` is rejected.
|
||||
|
||||
**Why this is in the contract rather than left to implementation.** Product Plan v3.1 §10.2 opens with the complaint that our numbers cannot be explained. A quantity you cannot reconstruct is a quantity you cannot defend to a bank, an inspector, or a seller disputing a payout. This journal is what turns "the stock says 3" into "the stock says 3, and here is every movement that made it 3."
|
||||
|
||||
## 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.
|
||||
|
||||
Added 2026-08-21 (FH-2.15): the import is idempotent by SKU/external key, so re-running the same file updates rather than duplicating. A row-level error never publishes a partial result. An applied import can be rolled back **as long as none of its products have appeared on a paid order** — after that, archive rather than delete.
|
||||
|
||||
## 6a. Digital fulfilment — code pools
|
||||
|
||||
Added 2026-08-21 (FH-2.11). We have no digital-goods story today, and it is one table.
|
||||
|
||||
```ts
|
||||
type FulfillmentMode = 'manual' | 'code_pool';
|
||||
|
||||
interface DigitalCode {
|
||||
id: string;
|
||||
marketplaceId: string;
|
||||
offerId: string;
|
||||
encryptedValue: string; // see Track S §4.2 envelope
|
||||
valueHash: string; // unique per (marketplaceId, offerId)
|
||||
status: 'available' | 'reserved' | 'assigned' | 'revoked';
|
||||
orderLineId?: string;
|
||||
createdAt: string;
|
||||
assignedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `FulfillmentMode` is a property of the offer. `code_pool` offers derive `available` from the count of `available` codes — the two must not be maintained independently.
|
||||
- `valueHash` is unique per `(marketplaceId, offerId)`, so importing the same code twice is refused by the database rather than by a check somebody can forget.
|
||||
- A code moves `available → reserved` under the same conditional-write rule as §3.1, and `reserved → assigned` only on confirmed payment.
|
||||
- **A code is returned to the browser only when the order is `paid`, `processing`, or `fulfilled`.** Any earlier state returns the line with an empty code list — not a masked value, not a placeholder.
|
||||
- Revocation is terminal and audited (Track S §3).
|
||||
|
||||
**Acceptance:** an unpaid order never yields a code, through the UI or through a direct API call with a valid session.
|
||||
|
||||
## 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,131 +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. Idempotency belongs in the schema, not in a handler
|
||||
|
||||
Added 2026-08-21 (FH-2.2). [Phase 1 §6.3–6.4](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) already require that a replayed webhook be a no-op and that order creation be idempotent. Both are stated as *behaviour*. Behaviour written as an `if` gets deleted by someone refactoring in eighteen months, and the failure mode is a double charge. Make the database refuse instead:
|
||||
|
||||
```
|
||||
UNIQUE (payment.idempotency_key)
|
||||
UNIQUE (payment_webhook_event.provider, payment_webhook_event.event_key)
|
||||
```
|
||||
|
||||
Handling:
|
||||
|
||||
- **Payment creation.** `Idempotency-Key` is required on the create call. If a payment already exists for that key: same order and marketplace → return the existing payment unchanged; different order or marketplace → `409`, never silently create a second one.
|
||||
- **Webhook receipt.** Insert the event row *first*. A unique-violation is the duplicate signal — respond `{ accepted: true, duplicate: true }` and stop. Only a successful insert proceeds to apply the status change. Mark `processedAt` after applying, so a crash between insert and apply is visible as an unprocessed row rather than a lost event.
|
||||
- **`event_key`** is the provider's event id where one exists, and `sha256(rawBody)` where it does not. A provider that sends no event id must still be replay-safe.
|
||||
- **Signature verification runs against the raw request body**, before any parsing or re-serialization. Verify-after-parse is verify-nothing.
|
||||
- **Poll as reconciliation, not as primary.** A scheduled job re-checks provider status for payments still `pending` within the last 24 hours and applies the result through the same state-machine path as the webhook. Transient provider failures are swallowed; the next tick retries. Never a fixed delay, never a UI-driven poll standing in for a missed webhook.
|
||||
|
||||
**Acceptance:** the same provider event delivered twice completes the order once, moves stock once, and emits one notification. This is scenario 10 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test.
|
||||
|
||||
## 6. 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,228 +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' | 'yandex_id' | 'telegram' | 'max'; // yandex_id added 2026-08-21, FH-4.5
|
||||
providerUserId: string;
|
||||
email?: string; // VK frequently returns none - never require it
|
||||
phone?: string;
|
||||
displayName?: string;
|
||||
verifiedAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
interface ContactMethod {
|
||||
customerId: string;
|
||||
type: 'email' | 'phone';
|
||||
value: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
interface ContactChannel {
|
||||
customerId: string;
|
||||
provider: 'telegram' | 'vk' | 'max';
|
||||
chatId: string;
|
||||
verified: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
deliveryEnabled: boolean;
|
||||
}
|
||||
|
||||
interface MessagingConsent {
|
||||
customerId: string;
|
||||
channel: string;
|
||||
purpose: 'marketing' | 'order_service_messages';
|
||||
grantedAt?: string;
|
||||
revokedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Telegram is demoted from sole identity to one `ExternalIdentity` provider among several — it must remain fully functional, just no longer the only path.
|
||||
|
||||
## 2. Sprint 8.2 — Social identity: VK ID first, Yandex ID second
|
||||
|
||||
Rewritten 2026-08-21 (FH-4.1–FH-4.5). Previously this section specified a VK-only pair of endpoints where the callback took `{ code, codeVerifier }` from the browser. Two changes: the surface is provider-agnostic, and the PKCE verifier stops travelling through the client.
|
||||
|
||||
### 2.1 Surface
|
||||
|
||||
```
|
||||
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||
```
|
||||
|
||||
`{provider}` is `vk` or `yandex` today; `telegram` and `max` join it when §4 migrates them onto `ExternalIdentity`. One controller, one strategy object per provider. The frontend gateway is a single interface (`src/app/core/identity/services/social-identity-gateway.interface.ts`) — providers differ only in a path segment, because everything that actually differs between them is backend-side.
|
||||
|
||||
### 2.2 The backend owns state and the code verifier
|
||||
|
||||
`/authorize` generates `state` and `code_verifier`, stores `{ state, codeVerifier, marketplaceId, returnTo, expiresAt }` server-side or in a signed `HttpOnly` cookie, TTL 10 minutes, **single use — deleted on first presentation**. It returns (or redirects to) the provider URL carrying `code_challenge` (S256) and `state`.
|
||||
|
||||
`/callback` validates `state`, exchanges the code using the stored verifier, fetches the profile, resolves or links the `ExternalIdentity`, issues the customer session cookie ([Track S §2.1](TRACK-S-SECURITY-RBAC-CONTRACT.md)), and redirects to `returnTo`.
|
||||
|
||||
- The client never sees a client secret, an access token, or a code verifier. We are a confidential client; a browser-held verifier buys nothing and adds a place to steal it from.
|
||||
- An unknown, expired, or replayed `state` is a generic error. Do not distinguish the cases to the caller.
|
||||
- `returnTo` is validated against the tenant's own verified origin. It is an open redirect otherwise.
|
||||
|
||||
### 2.3 Identity resolution
|
||||
|
||||
- A repeat login for the same `providerUserId` resolves to the same `Customer`, never a duplicate.
|
||||
- `UNIQUE (provider, providerUserId)`. If a provider account is already bound to a *different* `Customer`, that is an identity conflict — route to controlled resolution, never silently rebind (plan §14.3). The unique index is what enforces this; a service-layer check is not sufficient.
|
||||
- Whether one VK account across two of our storefronts is one `Customer` or two is a **product decision that must be made before implementation** (FH-0.1). `Customer.marketplaceId` currently implies two, and two is the safer default for data protection.
|
||||
- Email is optional on `Customer`. Yandex returns one in most cases; VK frequently does not.
|
||||
|
||||
### 2.4 Per-tenant OAuth applications
|
||||
|
||||
Client id and secret are per marketplace, stored with the [Track S §4.2](TRACK-S-SECURITY-RBAC-CONTRACT.md) envelope: `{ clientId, clientSecret, scopes[], redirectUri }`. Never returned by any endpoint.
|
||||
|
||||
**The redirect_uri problem, which must be solved before any code is written.** Both providers validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain and we cannot let tenants supply their own. Resolution: one **central identity host** as the sole registered callback, the origin tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant API exchanges for its session cookie. This is a one-way door — retrofitting it after the first provider is live is expensive.
|
||||
|
||||
### 2.5 Provider notes
|
||||
|
||||
Confirm exact parameter and scope names against live provider documentation before implementing; both providers have revised their flows recently.
|
||||
|
||||
**VK ID** — OAuth 2.1, PKCE mandatory (S256).
|
||||
|
||||
```
|
||||
authorize GET https://id.vk.com/authorize
|
||||
client_id, redirect_uri, response_type=code,
|
||||
code_challenge, code_challenge_method=S256, state, scope
|
||||
token POST https://id.vk.com/oauth2/auth
|
||||
grant_type=authorization_code, code, code_verifier,
|
||||
device_id, client_id, redirect_uri
|
||||
profile POST https://id.vk.com/oauth2/user_info
|
||||
logout https://id.vk.com/oauth2/logout (call on unlink)
|
||||
```
|
||||
|
||||
**The callback returns `device_id` alongside `code`, and the token exchange fails without it.** This is the single most common VK ID integration bug; it is in this contract so it is not rediscovered at debugging time.
|
||||
|
||||
**Yandex ID** — OAuth 2.0, PKCE supported; use it.
|
||||
|
||||
```
|
||||
authorize GET https://oauth.yandex.ru/authorize
|
||||
response_type=code, client_id, redirect_uri, state,
|
||||
code_challenge, code_challenge_method=S256
|
||||
token POST https://oauth.yandex.ru/token
|
||||
grant_type=authorization_code, code, code_verifier
|
||||
HTTP Basic: client_id:client_secret
|
||||
profile GET https://login.yandex.ru/info?format=json
|
||||
Authorization: OAuth <access_token>
|
||||
-> id, login, default_email, default_phone, psuid
|
||||
```
|
||||
|
||||
Yandex is a second strategy object against the same surface, not a second integration. Build it after VK works.
|
||||
|
||||
### 2.6 Migrating Telegram onto `ExternalIdentity` (FH-4.6)
|
||||
|
||||
Added 2026-08-21. Telegram is the current app's only customer login and it does not go through §2's OAuth surface — it authenticates via a QR/bot flow owned by `@marketplaces/auth`. The migration makes it *one identity among several* without changing how it authenticates:
|
||||
|
||||
- On a successful Telegram login the backend writes (or updates) an `ExternalIdentity` with `provider: 'telegram'` and `providerUserId` = the Telegram user id, under the same `UNIQUE (provider, providerUserId)` and same identity-conflict rule as §2.3. Telegram stops being a special-cased column and becomes a row like any other provider.
|
||||
- Telegram appears in `GET /me/identities` and is removable through `POST /api/identity/v1/telegram/unlink`, subject to the **last-identity rule**: the backend refuses to unlink a customer's only remaining identity (`409`, a login they cannot get back). The frontend gateway's `unlink()` already accepts any `ExternalIdentityProvider`, not just the OAuth ones, so the linking UI (FH-4.7) drives this without a second code path.
|
||||
- **The customer/admin session split their audit flagged is closed here.** A Telegram customer session (`marketplace_session`) and an admin session (`bo_session`) are different cookies checked by different guards ([Track S §2.1](TRACK-S-SECURITY-RBAC-CONTRACT.md)); the same Telegram account authenticating as a customer must never satisfy an admin guard, and vice versa. Migrating Telegram to `ExternalIdentity` on the customer side does not grant it any admin scope — admin membership is a separate axis.
|
||||
- Telegram's messaging binding (§4 `BotConversationBinding`, delivery updates) is a *contact channel*, distinct from the *identity* row added here. One provider can be both; they are separate records so unlinking the identity does not silently kill an order's delivery conversation, and vice versa.
|
||||
|
||||
Client state today: `SocialProvider` (`vk` | `yandex`) is the set with an OAuth authorize redirect; `ExternalIdentityProvider` (`vk_id` | `yandex_id` | `telegram` | `max`) is the set that can be listed and unlinked. `unlink()` takes the wider type; `getAuthorizeUrl()` takes the narrower one. See `src/app/core/identity/services/social-identity-gateway.interface.ts`.
|
||||
|
||||
## 3. Sprint 8.3 — Email/phone OTP (after VK ID), positioned as recovery (FH-4.8)
|
||||
|
||||
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.
|
||||
|
||||
Concretely, "recovery/fallback" means: email/phone OTP is offered as a way back in when a customer's linked messenger identity is unreachable, and as a second factor a customer can add — never as the front-and-centre first option on the login surface, which is VK ID (and Telegram, where it is already the norm). It is one more `ExternalIdentity`/`ContactMethod` on the same customer, not a parallel account.
|
||||
|
||||
## 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 and Yandex ID login buttons on the storefront. The client half already exists and is provider-agnostic: `SocialLoginButtonComponent` behind `SOCIAL_IDENTITY_GATEWAY`, with a real HTTP gateway waiting on §2.1's endpoints. Adding Yandex once VK works is a `provider` input, not new code.
|
||||
- Account linking screen (`GET /me/identities`, link/unlink), including the identity-conflict resolution path from §2.3.
|
||||
- MAX/Telegram linking UI (one-time code flow).
|
||||
- Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
|
||||
- Manager-facing conversation view (message history, current delivery state, accept handoff).
|
||||
@@ -1,240 +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.
|
||||
|
||||
### 5.1 Revision immutability, stated precisely
|
||||
|
||||
Added 2026-08-21 (FH-2.7). §5 says a published revision "becomes immutable" and that rollback creates a new revision. The details that make that true:
|
||||
|
||||
- `version` is an integer, assigned as `max(version) + 1` for the marketplace, inside the publishing transaction. `UNIQUE (marketplaceId, version)`.
|
||||
- A revision row stores the **materialized** snapshot — the effective content and configuration at publish time, not references that can later resolve differently. A product renamed tomorrow does not retroactively change what was published today.
|
||||
- Publishing flips a single `publishedRevision` pointer in the same transaction that writes the snapshot. There is no window in which a marketplace is serving a half-published state.
|
||||
- Rollback reads revision *n*, writes it as revision *max+1*, and points at that. Revision *n* is untouched. History only grows.
|
||||
- **Operational state does not travel with a revision.** Inventory, reservations, orders and payments are live data. Rolling back last week's design must not roll back this week's stock. This is worth stating because it is the single most tempting shortcut in a revision system and the most expensive one to discover in production.
|
||||
|
||||
### 5.2 Clone
|
||||
|
||||
Added 2026-08-21 (FH-2.7). Launching marketplace *n+1* from an existing one is the platform's core promise, so what a clone does and does not carry is a contract, not an implementation choice.
|
||||
|
||||
Carried: theme and design configuration, sections, pages, navigation, category tree, collections, and offer assignments.
|
||||
|
||||
**Not** carried, under any flag: domains, admin users and memberships, customers, customer sessions, orders, payments, payment credentials, webhook secrets, audit history.
|
||||
|
||||
Inventory in the clone starts at zero unless a platform-scope role explicitly opts otherwise. Cloning stock by default means a new storefront can sell units that a different storefront is holding.
|
||||
|
||||
The category tree is copied by a topological walk with explicit cycle detection — a cycle is a `400` naming the offending categories, never an infinite loop and never a silently truncated tree.
|
||||
|
||||
### 5.3 Preview is signed and read-only
|
||||
|
||||
Added 2026-08-21 (FH-2.6). We have preview in the product and no preview safety anywhere in these contracts.
|
||||
|
||||
```
|
||||
POST /api/admin/v2/marketplaces/{id}/preview-token -> { url, expiresAt }
|
||||
```
|
||||
|
||||
- The token is an HMAC signature over `{ marketplaceId, expiresAt, nonce }`, TTL 15 minutes, delivered as an `HttpOnly` cookie scoped to the preview host. Signature comparison is constant-time; an invalid or expired token is `404`, not `401` — an unpublished storefront should not confirm its own existence.
|
||||
- **While a preview cookie is present, every non-`GET` on the public API returns `404`.** Enforced by a hook ahead of routing, not per endpoint. Preview exists to look at an unpublished storefront, never to transact against one — otherwise preview becomes a way to place real orders and move real stock against a design nobody approved.
|
||||
- Preview responses carry `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
## 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**.
|
||||
|
||||
Added 2026-08-21 (FH-2.5), the parts that decide whether the two rules above actually hold:
|
||||
|
||||
- **Normalization is specified, not assumed:** lowercase, strip a trailing dot, strip the port, then match. `Shop.Example.COM.:443` and `shop.example.com` are one tenant. A normalization that differs between the lookup and the domain-verification write is a tenant-isolation bug.
|
||||
- **A domain row only resolves once `verifiedAt` is set** and the marketplace is in a serving state. An unverified domain is `404`, so pointing DNS at us is not by itself enough to make someone else's brand serve.
|
||||
- Host lookups may be cached briefly (~30 s) — with **explicit invalidation** on domain add, verify, remove, and marketplace state change. Without invalidation, a suspended marketplace keeps serving for the length of the cache, which is the wrong side to fail on.
|
||||
- `Host` is read from the verified proxy header chain, with the proxy configured to overwrite rather than append what the client sent. A client-supplied `Host`/`X-Forwarded-Host` is not evidence.
|
||||
- **No public endpoint accepts a `marketplaceId`** in path, query, or body. If one does, the Host check is decoration.
|
||||
|
||||
**Acceptance:** a request with an unknown or unverified Host returns 404 and no data belonging 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,51 +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.
|
||||
>
|
||||
> **Building from the fork harvest? [HARVEST-BACKEND-REQUIREMENTS.md](HARVEST-BACKEND-REQUIREMENTS.md)** — one consolidated view of every mechanism harvested from the parallel platform (the `FH-*` items): the nine release invariants, each requirement with its mechanism and phase-contract reference, the 15 acceptance tests, and the build order. Start here for the security/correctness hardening; the phase docs hold the full entity shapes.
|
||||
|
||||
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,179 +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).
|
||||
|
||||
### 2.1 Session model
|
||||
|
||||
Added 2026-08-21 (FH-2.3). §8 specifies password *change*; this specifies what a session actually is, because nothing in this series did.
|
||||
|
||||
- A session token is 32 random bytes, base64url. The server stores **only its SHA-256 hash**. A database read must not yield a usable credential.
|
||||
- Delivered as `HttpOnly; Secure; SameSite=Lax` cookie. Never in a response body, never in `localStorage`, never readable by script. A token in web storage is a token every XSS gets for free.
|
||||
- Rows carry `expiresAt`, `revokedAt`, `ip`, `userAgent`. Admin sessions expire in 12 hours; storefront customer sessions in 30 days.
|
||||
- Validation rejects on any of: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not yet enrolled.
|
||||
- **One cookie name per contour** — the backoffice, the order-manager portal (§8a) and the storefront must not share a session cookie. A customer session must never satisfy an admin guard, and the way to guarantee that is for them to be different cookies checked by different guards, not the same cookie checked more carefully.
|
||||
- Password change revokes every live session for that user **in the same transaction** as the password write.
|
||||
|
||||
Credential storage: Argon2id, `memoryCost 65536, timeCost 3, parallelism 1`, minimum 16 characters. Second factor (TOTP) is **mandatory** for every platform- and marketplace-scope role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token plus the `otpauth://` URI, and issues no session until the factor is confirmed. An enrolment token is not a session and grants nothing else.
|
||||
|
||||
### 2.2 Origin allowlist on every cookie-authenticated mutation
|
||||
|
||||
Added 2026-08-21 (FH-2.4). Cookie auth without an origin check is CSRF. One hook, ahead of routing:
|
||||
|
||||
- Any non-`GET`/`HEAD`/`OPTIONS` request to `/api/admin/*`, `/api/platform/*` or `/api/manager/*` whose `Origin` header is not in the configured allowlist → `403`, before the handler runs.
|
||||
- CORS uses the same allowlist with `credentials: true`. Not `*`, not reflected.
|
||||
- The allowlist is configuration, not code, and is per-environment.
|
||||
|
||||
This is a dozen lines and it closes the entire class. It is cheap enough that there is no reason for it to arrive late.
|
||||
|
||||
## 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.
|
||||
|
||||
### 4.2 Encryption envelope for stored secrets
|
||||
|
||||
Added 2026-08-21 (FH-2.9). §4 says credentials live in secret storage and never leave the backend. This is the storage format, so that "encrypted" is a specification rather than an adjective.
|
||||
|
||||
```
|
||||
v1.<base64url iv>.<base64url authTag>.<base64url ciphertext>
|
||||
```
|
||||
|
||||
- AES-256-GCM. 12-byte random IV per value, never reused. Key is 32 bytes, supplied by environment or secret manager, never in the repository.
|
||||
- The leading version tag exists so the algorithm can be rotated without guessing at the format of existing rows.
|
||||
- Decryption happens inside the service that uses the secret. A decrypted value is never placed on a DTO, never logged, never returned by any endpoint — including to a `PLATFORM_OWNER`. Backoffice shows presence, last-rotated, and a fingerprint; it does not show the value.
|
||||
- Fingerprints for display or matching are `HMAC-SHA256(key, value)`, not the value truncated.
|
||||
- Redirect and callback URLs are built backend-side from the tenant's verified domain and validated against an allowlist before being returned. The browser receives a URL to navigate to, never the material used to construct it.
|
||||
|
||||
This covers payment provider credentials, connector credentials, bot tokens, FX source keys, and the per-tenant OAuth app secrets for [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md).
|
||||
|
||||
**Acceptance:** no credential value appears in any API response, JS bundle, log line, or browser storage. The bundle half is enforced in CI by `scripts/ci/scan-bundle.sh`.
|
||||
|
||||
## 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` = `{projectName}2026$`, flagged `mustChangePassword: true`
|
||||
- Login succeeds but every non-auth request 403s with `PASSWORD_CHANGE_REQUIRED` until password is changed.
|
||||
|
||||
```
|
||||
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
|
||||
```
|
||||
|
||||
A `MARKETPLACE_ADMIN` can then provision sub-admins scoped to their own marketplace only — mirrors the seller-team invite pattern in [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (`POST /api/seller/v1/team/invite`):
|
||||
|
||||
```
|
||||
POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }
|
||||
GET /api/admin/v2/team?marketplaceId=
|
||||
PATCH /api/admin/v2/team/{userId} { role }
|
||||
DELETE /api/admin/v2/team/{userId}
|
||||
```
|
||||
|
||||
Invariants:
|
||||
- `role` must be one of the `MarketplaceRole` set (§1) — never `PlatformRole`. Backend rejects any attempt to grant a platform-scope role through this endpoint (`403 SCOPE_ESCALATION_DENIED`).
|
||||
- `marketplaceId` is forced server-side to the caller's own tenant scope — request body value is ignored/validated, never trusted.
|
||||
- Every invite/role-change/removal is an audit-logged action (§3, `action: 'admin_team.invited' | 'admin_team.role_changed' | 'admin_team.removed'`).
|
||||
- Role grants at `MARKETPLACE_ADMIN` level require step-up auth (§6).
|
||||
- Invited admins get their own credentials (email + set-password flow), not the shared bootstrap login — the bootstrap account is for first login only and should be rotated/retired once real admins exist.
|
||||
|
||||
## 8a. Order manager is a separate contour, not a narrower menu
|
||||
|
||||
Added 2026-08-21 (FH-2.14). `ORDER_MANAGER` is one of the 17 roles in §1, which today implies a smaller version of the same backoffice. Make it a separate surface instead:
|
||||
|
||||
- Its own URL and its own shell, its own login, and its own session cookie (§2.1). An order manager who somehow obtained a backoffice URL gets `403` from the guard, not a half-rendered admin page.
|
||||
- Scope comes from **membership rows**, never from configuration. (The reference implementation we reviewed pins the manager's marketplace with an environment variable — that is the one part of it not to copy. An env string is not an access-control decision and cannot express two marketplaces.)
|
||||
- Reachable data is orders, their customers, and the fulfilment actions the role is permitted. Catalog, design, domains, payment settings, platform users and platform settings are not merely hidden — the endpoints refuse.
|
||||
- PII is masked in list views and revealed in detail only with the permission for it. Both the reveal and any export are audit-logged (§3, §7).
|
||||
|
||||
The reason to spend a separate contour on this rather than more guards: the people who work orders all day are the largest group of accounts and the least likely to be security-trained. Reducing what their credential can reach is worth more than adding checks to what it can.
|
||||
|
||||
## 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.
|
||||
@@ -11,9 +11,9 @@ tags: [architecture, auth, payment, monorepo]
|
||||
|
||||
## Context
|
||||
|
||||
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) and [Phase 7](../../backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
|
||||
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/BACKEND-INTEGRATION.md) and [Phase 7](../../backend/BACKEND-INTEGRATION.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
|
||||
|
||||
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
|
||||
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -43,4 +43,4 @@ The Verdaccio instance still runs but nothing depends on it. Making a registry t
|
||||
- `marketplaces` loses direct edit access to auth/payment source — changes go through the package's own repo/PR/release cycle. Slower iteration, but consistent behavior across all consuming projects.
|
||||
- ~30 call sites in `marketplaces` (see `core/auth`, `core/admin-auth`, `services/auth.service.ts`, interceptors) need import rewiring during migration — tracked as follow-up work, not done in this ADR.
|
||||
- New failure mode: `marketplaces` builds now depend on `sources.vitanova.network` being reachable. A branch ref also tracks its tip, so an install can pick up a new build — acceptable while the package churns, but pin to a commit SHA once it stabilises.
|
||||
- [TRACK-S-SECURITY-RBAC-CONTRACT.md](../../backend/TRACK-S-SECURITY-RBAC-CONTRACT.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.
|
||||
- [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.
|
||||
|
||||
@@ -17,13 +17,13 @@ Their request arrived written in their own vocabulary. Building against that voc
|
||||
|
||||
Three facts about our current model made the ask non-trivial:
|
||||
|
||||
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). No company, no project.
|
||||
2. Payments carry no store dimension ([Phase 1](../../backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
||||
3. We have no partner-facing write API at all. [Phase 4](../../backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) is outbound/ingest — the opposite direction.
|
||||
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/BACKEND-INTEGRATION.md)). No company, no project.
|
||||
2. Payments carry no store dimension ([Phase 1](../../backend/BACKEND-INTEGRATION.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
||||
3. We have no partner-facing write API at all. [Phase 4](../../backend/BACKEND-INTEGRATION.md) is outbound/ingest — the opposite direction.
|
||||
|
||||
## Decision
|
||||
|
||||
Build one generic partner provisioning API. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](../../backend/PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
Build one generic partner provisioning API. Contract: [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md).
|
||||
|
||||
### 1. Partner-specific behaviour is config, never schema
|
||||
|
||||
@@ -52,7 +52,7 @@ The partner asked us to choose between per-company, per-project, and per-store c
|
||||
|
||||
### 5. Seller is excluded from the hierarchy
|
||||
|
||||
`Seller` ([Phase 5](../../backend/PHASE-5-SELLER-PORTAL-CONTRACT.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
||||
`Seller` ([Phase 5](../../backend/BACKEND-INTEGRATION.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/BACKEND-INTEGRATION.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
||||
|
||||
### 6. RoutingContext lands in Phase 1 before implementation, not after
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ That splits every harvested improvement into one of five lanes:
|
||||
| **B — Contracts** | We write the requirement; backend implements | `docs/backend/*.md` |
|
||||
| **C — Packages** | Ships in `@marketplaces/auth` (external repo `vitanovaPackages`) | package repo + DI wiring here |
|
||||
| **D — Infra/Ops** | Deploy scripts and runbook | `scripts/deploy/`, `docs/DEPLOYMENT.md` |
|
||||
| **E — Process** | Governance, gates, policy | `docs/backend/BACKEND-HANDOFF.md`, ADRs |
|
||||
| **E — Process** | Governance, gates, policy | `../../backend/BACKEND-INTEGRATION.md`, ADRs |
|
||||
|
||||
Anything that would require us to stand up Prisma, Postgres, or NestJS in *this* repo is out of scope. It becomes a Lane B contract line instead.
|
||||
|
||||
@@ -89,7 +89,7 @@ WHERE "marketplaceId" = $mp AND "variantId" = $variant
|
||||
RETURNING "id"
|
||||
```
|
||||
|
||||
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `PHASE-6-CART-CHECKOUT-CONTRACT.md` as a normative requirement, not a suggestion.
|
||||
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `../../backend/BACKEND-INTEGRATION.md` as a normative requirement, not a suggestion.
|
||||
|
||||
**Idempotency as a unique constraint.** `Payment.idempotencyKey UNIQUE` and `PaymentWebhookEvent @@unique([provider, eventKey])`. A duplicate insert throws, and the catch returns `{accepted: true, duplicate: true}`. Replay protection becomes structurally impossible to forget, versus an `if` somebody eventually deletes. Goes into `PHASE-7`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user