docs(backend): consolidated harvest requirements as one buildable file

HARVEST-BACKEND-REQUIREMENTS.md - single index the backend builds the
fork harvest from, so the FH-* mechanisms are not scattered across nine
phase contracts:

- the nine release invariants (the acceptance gate)
- every FH-* requirement with its exact mechanism and phase-contract
  reference, grouped by area (correctness core, sessions/access,
  tenancy/publish/content, identity, operations)
- 15 acceptance tests mapped to the invariant each guards
- build order, and what the frontend already delivered so the backend
  builds to a known target rather than guessing

Linked from the backend README index.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-22 12:09:38 +04:00
parent b4772d10c7
commit 52fb52888f
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
# 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.15.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.12.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.32.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. 67, 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 15 and 8.
2. **Publish & content:** FH-2.6, FH-2.7, FH-2.10, FH-2.13, FH-2.15 (invariants 67).
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) §00a.

View File

@@ -5,6 +5,8 @@
> **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. > **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. > **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. 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.