Files
marketplaces/docs/backend/HARVEST-BACKEND-REQUIREMENTS.md
sdarbinyan 52fb52888f 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>
2026-08-22 12:09:38 +04:00

17 KiB
Raw Blame History

Harvest — Consolidated Backend Requirements

Date: 2026-08-22 Branch: improvements/fork-harvest Source: FORK-ANALYSIS-2026-08-21.md · decision ADR-0006

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. 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 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 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 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 S
FH-2.11 Digital code pools. `FulfillmentMode: manual code_pool. DigitalCodewithAVAILABLE/RESERVED/ASSIGNED/REVOKED, encrypted value, valueHashunique per(marketplace, offer). availablefor acode_pooloffer derives from the count of available codes. Codes moveavailable→reservedunder the FH-2.1 conditional write,reserved→assignedonly on confirmed payment. A code is returned to the browser only when the order ispaid/processing/fulfilled`; earlier states return an empty code list. PHASE-3 §6a

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 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 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 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 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 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 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 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 §6 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 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 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 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 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 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 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 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 §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).


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 §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 §00a.