Files
marketplaces/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md
sdarbinyan f9e09b1757 docs(backend): harvest platform mechanisms into the contracts (Wave 2, FH-E.1-E.4)
Writes the 14 harvested mechanisms from FORK-ANALYSIS-2026-08-21.md into
the backend contracts. Each section is dated 2026-08-21 and tagged FH-*
so any wording traces back to why it is worded that way.

The through-line: several contracts stated correctness as behaviour
("the webhook must be idempotent"). Behaviour written as an if-statement
gets deleted by a refactor and the failure mode is a double charge. These
sections restate it as schema and mechanism.

PHASE-3  3.1 conditional-write reservation, 409 on zero rows, cart-wide
             rollback, 15 min TTL
         3.2 InventoryMovement append-only journal with resultingAvailable
         6   bulk import idempotent by SKU, rollback while unsold
         6a  digital code pools, revealed only when paid
PHASE-7  5   unique constraints for payment idempotency and webhook
             replay, insert-first handling, signature over raw body,
             24h poll as reconciliation not primary
TRACK-S  2.1 session model - 32 bytes stored as SHA-256 only, HttpOnly,
             one cookie per contour, Argon2id params, mandatory TOTP
         2.2 origin allowlist ahead of routing on every cookie mutation
         4.2 AES-256-GCM envelope for stored secrets, HMAC fingerprints
         8a  order manager as a separate contour, scoped by membership
             rows rather than by configuration
PHASE-9  5.1 revision immutability, version = max+1, pointer flipped
             in-transaction, operational state does not travel
         5.2 clone carry / no-carry list, inventory to zero
         5.3 signed read-only preview, non-GET 404s while previewing
         6   host normalization, verifiedAt required, cache invalidation
PHASE-10 3a  server re-runs the editor's validation, clamp-and-fallback
PHASE-2  3.1 order publicToken, snapshot completeness, never updated

FH-2.12 rejected on the merits: our marketplace lifecycle state machine
is richer than theirs, adopting it would be a downgrade. Recorded in the
TODO so it is not raised again.

Also adds BACKEND-HANDOFF.md sections 0 and 0a - nine falsifiable
invariants as a release gate, each cross-referenced to the contract that
specifies it, plus PR and release discipline. And ADR-0006 recording what
we take, what we reject, what we keep because ours is better, and the
organizational question it deliberately does not settle.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:12:05 +04:00

12 KiB
Raw Blame History

Phase 9 Backend Contract — Tenant Registry, Domain Automation, Publish Model

Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 9 (Sprints 9.19.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.

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.

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.

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