Files
marketplaces/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md

241 lines
12 KiB
Markdown
Raw Normal View History

# 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.19.3). Covers plan §4.3, §8.
**Status: ready to build.** Zero `hostinger` references exist in the codebase today.
---
## 1. Entities
2026-08-18 11:22:24 +04:00
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;
2026-08-18 11:22:24 +04:00
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.
2026-08-18 11:22:24 +04:00
### 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.
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
### 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**.
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
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).