From 52fb52888fbb8b986ed6d8f985813c04309caab1 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 22 Aug 2026 12:09:38 +0400 Subject: [PATCH] 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 --- docs/backend/HARVEST-BACKEND-REQUIREMENTS.md | 134 +++++++++++++++++++ docs/backend/README.md | 2 + 2 files changed, 136 insertions(+) create mode 100644 docs/backend/HARVEST-BACKEND-REQUIREMENTS.md diff --git a/docs/backend/HARVEST-BACKEND-REQUIREMENTS.md b/docs/backend/HARVEST-BACKEND-REQUIREMENTS.md new file mode 100644 index 0000000..6192537 --- /dev/null +++ b/docs/backend/HARVEST-BACKEND-REQUIREMENTS.md @@ -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...` 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 `). 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. diff --git a/docs/backend/README.md b/docs/backend/README.md index 4ebcab7..e7659c8 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -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. > > **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.