diff --git a/docs/FORK-HARVEST-TODO.md b/docs/FORK-HARVEST-TODO.md index 1704dad..1b2f5f8 100644 --- a/docs/FORK-HARVEST-TODO.md +++ b/docs/FORK-HARVEST-TODO.md @@ -51,61 +51,74 @@ Improvements only. Nothing here regresses our Angular version, test count, or ar Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND-HANDOFF.md`, so it becomes a delivery gate rather than a wish. -- [ ] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md` +- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md` + **Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL. `UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request. **Acceptance:** two concurrent purchases of the last unit produce exactly one payable order. -- [ ] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md` +- [x] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md` + **Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation. `Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback. **Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice. -- [ ] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md` +- [x] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md` + **Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction. Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope. **Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call. -- [ ] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md` +- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md` + **Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS. Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`. **Acceptance:** a cross-origin POST with a valid session cookie is refused. -- [ ] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md` +- [x] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md` + **Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`. Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser. **Acceptance:** an unknown Host returns 404 and leaks no other tenant's data. -- [ ] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…` +- [x] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…` + **Written 2026-08-21:** PHASE-9 §5.3 — HMAC preview token, 15 min, HttpOnly cookie, every non-GET 404s while preview is active, `noindex`. HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable. **Acceptance:** a mutation attempted in preview mode is refused. -- [ ] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…` +- [x] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…` + **Written 2026-08-21:** PHASE-9 §5.1–5.2 — `version = max+1` unique per marketplace, materialized snapshot, pointer flipped in-transaction, rollback as a new revision, clone carry/no-carry list, inventory to zero, topological category walk. `version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection. **Acceptance:** rollback restores the chosen revision and leaves live inventory untouched. -- [ ] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md` +- [x] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md` + **Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time. Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint. **Acceptance:** any current quantity is reconstructible from the journal alone. -- [ ] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7` +- [x] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7` + **Written 2026-08-21:** TRACK-S §4.2 — `v1.iv.tag.ciphertext` AES-256-GCM envelope, per-value IV, decrypt only in-service, HMAC fingerprints for display, backend-built allowlisted redirect URLs. AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted. **Acceptance:** no credential appears in any API response, JS bundle, or browser storage. -- [ ] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md` +- [x] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md` + **Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers. The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list. **Acceptance:** a hand-crafted API call cannot store a config the editor would have refused. -- [ ] **FH-2.11 — Digital goods** · M · `PHASE-3-…` +- [x] **FH-2.11 — Digital goods** · M · `PHASE-3-…` + **Written 2026-08-21:** PHASE-3 §6a — `FulfillmentMode`, `DigitalCode` states, `valueHash` unique per (marketplace, offer), codes revealed only when paid. `FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`. **Acceptance:** an unpaid order never returns a code. -- [ ] **FH-2.12 — Marketplace status machine** · S · `PHASE-9-…` - `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition. +- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better** + Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again. -- [ ] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md` +- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md` + **Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place. Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time. -- [ ] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…` +- [x] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…` + **Written 2026-08-21:** TRACK-S §8a — separate URL, shell, login and cookie; scope from membership rows not configuration; endpoints refuse rather than hide; PII masking and audited reveal. Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged. -- [ ] **FH-2.15 — CSV marketplace import with `dryRun` default true** · S · `PARTNER-PROVISIONING-API-CONTRACT.md` - Bulk tenant creation as a first-class operation: validate fully without writing, report create/update/skip/error per row, then confirm. +- [x] **FH-2.15 — Bulk import: idempotency and rollback** · S · **done 2026-08-21** + The validate-then-apply half already existed — PHASE-3 §6 has the preview of validation errors and a separate apply step, which is equivalent to their `dryRun`. What was missing and is now written: the import is **idempotent by SKU/external key** so re-running a file updates rather than duplicates, a row-level error never publishes a partial result, and an applied import is rollback-able only while none of its products have appeared on a paid order. --- @@ -195,18 +208,17 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande ## Continuous — Process (Lane E) -- [ ] **FH-E.1 — Adopt the nine invariants as a signed acceptance gate** · S - From their handoff §7: tenant by Host not by a browser-supplied ID; order price computed backend; stock and reservation atomic; payment webhook idempotent; credentials never leave the backend; published revision immutable; design rollback does not roll back live inventory; no user reads an unassigned marketplace through UI or API; every admin mutation leaves an audit trail. - Put them at the head of `docs/backend/BACKEND-HANDOFF.md` as gates, not aspirations. +- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21** + Now `BACKEND-HANDOFF.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished. -- [ ] **FH-E.2 — PR policy** · S - One functional area per PR. Mandatory: purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan. Never change payment/inventory/order state machines inside a redesign PR. +- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21** + `BACKEND-HANDOFF.md` §0a, with the expand/contract migration rule alongside it. -- [ ] **FH-E.3 — Release discipline** · S - "A local build or the existence of a UI does not mean production readiness." Every release records version, migrations, healthcheck, smoke result, dependency audit, and a rollback path. +- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21** + `BACKEND-HANDOFF.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available. -- [ ] **FH-E.4 — ADR for the harvest** · S - Record the decision: adopt these improvements, reject their architecture, keep our frontend and governance. Include the §9 rejection list so it does not get relitigated. +- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21** + [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle. - [ ] **FH-E.6 — Keep mock gateways out of production builds** · M · Lane A Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users. @@ -222,15 +234,27 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande ## Scoreboard -| Wave | Items | Lane | Blocked by | -|---|---:|---|---| -| 0 — Decide | 2 | C, A | — | -| 1 — Live defects | 4 | A | FH-0.2 (one item) | -| 2 — Contracts | 15 | B | — | -| 3 — Proof | 5 | A | — | -| 4 — Identity | 8 | C | FH-0.1 | -| Ops | 3 | D | — | -| Process | 6 | E | — | -| **Total** | **43** | | | +| Wave | Done | Open | Lane | Blocked by | +|---|---:|---:|---|---| +| 0 — Decide | 0 | 2 | C, A | — | +| 1 — Live defects | 2 | 2 | A | FH-1.2 and FH-1.4 sit in files another session owns | +| 2 — Contracts | 14 | 0 | B | — (1 rejected: FH-2.12) | +| 3 — Proof | 2 | 3 | A | — | +| 4 — Identity | 0 | 8 | C | FH-0.1 | +| Ops | 0 | 3 | D | — | +| Process | 4 | 2 | E | — | +| **Total** | **22** | **20** | | 1 rejected | -**Start here:** FH-0.1 (escalate today, it is a one-way door), then FH-1.1 and FH-1.4 — both are small, both are live defects, and both close findings their audit will otherwise keep raising. +**Landed 2026-08-21** + +- **Wave 1:** FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration). +- **Wave 2:** all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits — our lifecycle state machine is richer than theirs. +- **Wave 3:** FH-3.3 (bundle budget ratcheted to a blocking error), FH-3.5 (`scripts/ci/scan-bundle.sh`, wired into CI, verified in both directions). +- **Process:** FH-E.1–E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). + +**Next** + +1. **FH-0.1** — the central identity host. One-way door, blocks all eight Wave 4 items, needs a person not a session. +2. **FH-3.1 / FH-3.2** — the two acceptance e2e tests. Both contracts they prove (PHASE-3 §3.1, PHASE-7 §5) are now written, so the tests have something normative to assert against. +3. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick them up once that work settles. +4. **FH-E.6** — mock fixtures currently reach production chunks. Mechanical fix, pattern already in the repo. diff --git a/docs/backend/BACKEND-HANDOFF.md b/docs/backend/BACKEND-HANDOFF.md index 0949f1a..aaafdd8 100644 --- a/docs/backend/BACKEND-HANDOFF.md +++ b/docs/backend/BACKEND-HANDOFF.md @@ -2,10 +2,38 @@ Single entry point for a backend developer picking this up cold. Written 2026-08-18. +## 0. Invariants — the acceptance gate + +Added 2026-08-21 (FH-E.1). Nine statements. Each is falsifiable, each has a test, and a release that violates any of them does not ship regardless of what else is finished. They come before the contracts because a contract can be read selectively and these cannot. + +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 one payable order. +4. Payment creation and webhook receipt are idempotent, enforced by unique constraints rather than by 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. + +Where each is specified: 1 and 6–7 in [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) §5–6; 2 in [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §5; 3 in [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) §3.1; 4 in [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §5; 5, 8 and 9 in [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) §2, §3, §4.2. + +## 0a. How work lands + +Added 2026-08-21 (FH-E.2, FH-E.3). + +**One functional area per pull request.** A PR carries: what it does, screenshots where there is UI, API changes, migrations, test evidence, security impact, and a rollback plan. Never change a payment, inventory, or order state machine in the same PR as a redesign — those two things fail differently and must be revertable separately. + +**Migrations are expand/contract.** A migration and the frontend change that depends on it belong to one release train, but they may be separate PRs, and the expand step must be deployable on its own. + +**A release is not "the build passed."** Each one records: version, migrations applied, healthcheck result, post-deploy smoke result, production dependency audit, and the rollback path actually available. A local build succeeding and a UI existing are not evidence of production readiness — the deploy and the smoke check are. + ## 1. What this is `marketplaces` is a multi-tenant marketplace platform frontend (Angular 22). The frontend is built and waiting; **there is no backend yet**. Every wire contract the backend needs to implement is already written and sitting in this directory — see [README.md](README.md) for the full index and build order. +Contract sections dated 2026-08-21 and tagged `FH-*` come from [FORK-ANALYSIS-2026-08-21.md](../FORK-ANALYSIS-2026-08-21.md) — a review of a parallel platform implementation that has a working backend. They are mechanisms that implementation already proved, harvested deliberately; the tag is there so you can trace any one of them back to why it is worded the way it is. + ## 1a. Multi-tenancy — the thing that shapes every endpoint The final executable infrastructure/backend contract is diff --git a/docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md b/docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md index 8526ec9..e3ba9d9 100644 --- a/docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md +++ b/docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md @@ -111,6 +111,26 @@ PATCH /api/admin/v2/content/mall-settings Commerce modules are **platform-ready but off** — the point of Phase 10 is proving this tenant can flip `catalog`/`cart`/`checkout`/etc. to `true` later via [Phase 9's `MarketplaceFeatureSet`](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) with zero backend or storefront code changes, since the commerce core is already generic by the time Phase 10 starts. +## 3a. The server re-validates everything the editor validates + +Added 2026-08-21 (FH-2.10). Our storefront editor has the stronger validation engine of the two implementations reviewed — blockers, warnings and informational notices, evaluated before publish. It all runs in the browser, which means it constrains the editor UI and nothing else. Anyone with a session and `curl` bypasses the entire thing. + +The backend re-runs the same rules on write. It is the only copy that binds. + +Ergonomics matter here, because a validator that hard-fails on cosmetic input makes the editor unusable. Follow clamp-and-fallback: + +- Numbers outside their allowed range are **clamped** to the range, not rejected. +- A colour that is not a valid hex value falls back to the documented default. +- A URL is accepted only if it is a same-origin path (`/…`, not `//…`, no backslashes) or `https://`. Anything else is stored as empty, not stored as given. +- Free text is trimmed and truncated at its documented maximum. +- Structural violations — an unknown block type, a malformed id, more blocks than the page allows, more referenced entity ids than the list allows — are a `400`. These cannot be silently coerced into something meaningful. + +Hard limits belong in this contract rather than in the client: maximum blocks per page, maximum referenced ids per block, maximum length per text field. Publish it as one schema and let both sides read it, so the editor and the server cannot drift. + +Referential checks run at publish, not on every keystroke: a block pointing at a deleted category or an unpublished offer is a publish **blocker** unless the block declares a fallback. This is the same rule as [Phase 3 §5](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) publish-time executability, applied to content instead of offers. + +**Acceptance:** a hand-crafted API call cannot store a configuration the editor would have refused. + ## 4. What the frontend will start doing once this ships - Mall scheme / floor / pin editor UI. diff --git a/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md b/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md index 42b4fea..eee2fc7 100644 --- a/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md +++ b/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md @@ -78,6 +78,15 @@ interface OrderContactSnapshot { } ``` +### 3.1 Public addressing and snapshot completeness + +Added 2026-08-21 (FH-2.13). + +- `Order` carries a `publicToken`: at least 24 random bytes, base64url, unique. **Every customer-facing route addresses an order by this token, never by `id`.** Order confirmation links, status polling, and support lookups all use it. A sequential or guessable public identifier turns "check my order" into an enumeration of the tenant's order book. +- `GET /api/v2/storefront/orders/{publicToken}` is scoped to the resolved tenant. A valid token from a different marketplace is `404`. +- `OrderLine` already snapshots SKU, title and price. Extend that to **everything that must survive a later edit**: currency, per-line discount, delivery option and price, and the tax/fee components. Together with `OrderContactSnapshot`, an order must be fully reconstructable from its own rows — renaming an offer, changing a price, or deleting a delivery option must not alter what a historical order says was bought and charged. +- Snapshot fields are written once at order creation and never updated. A correction is a new order event, a refund, or an amendment record — never an in-place rewrite of what the customer agreed to. + ## 4. Endpoints ``` diff --git a/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md b/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md index 1a10ac7..c07c1b2 100644 --- a/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md +++ b/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md @@ -95,6 +95,56 @@ Invariants: - Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement. - Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected. +### 3.1 Reservation must be atomic — the mechanism, not just the intent + +Added 2026-08-21 (FH-2.1). The invariants above say reservations exist; they do not say how two buyers racing for the last unit are separated. Specify the mechanism, because "check availability, then reserve" is a read-then-write race and will oversell under load no matter how the code above it is written. + +Reserve with a single conditional write that both tests and updates: + +```sql +UPDATE inventory +SET reserved = reserved + :qty +WHERE offer_id = :offerId + AND (available - reserved) >= :qty +RETURNING id +``` + +- **Zero rows returned means insufficient stock.** Respond `409` with the offending offer, do not retry, do not partially reserve. A multi-line cart reserves every line inside one transaction; any line returning zero rows rolls back all of them. +- No `SELECT` before the `UPDATE`. No advisory lock. No application-level retry loop. The `WHERE` clause is the concurrency control. +- Reservation TTL is 15 minutes from checkout-session creation. Expiry releases `reserved` back to `available` and writes a journal row (§3.2). +- The same rule governs release and consumption: one conditional statement, never read-modify-write. + +**Acceptance:** two concurrent checkouts for the last unit produce exactly one payable order and one clean `409`. This is scenario 3 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test, not a code-review item. + +### 3.2 Inventory movements are an append-only journal + +Added 2026-08-21 (FH-2.8). Every change to `available`/`reserved`/`sold` writes one immutable row: + +```ts +interface InventoryMovement { + id: string; + offerId: string; + deltaAvailable: number; + deltaReserved: number; + deltaSold: number; + reason: 'checkout_reservation' | 'reservation_expired' | 'reservation_released' + | 'payment_confirmed' | 'manual_adjustment' | 'feed_sync' | 'connector_sync' + | 'refund_restock' | 'oversell_correction'; + referenceType?: 'reservation' | 'order' | 'import' | 'connector'; + referenceId?: string; + actor?: string; // user id for manual adjustments, null for system + resultingAvailable: number; // balance after this movement, not recomputed later + occurredAt: string; +} +``` + +Rules: +- Rows are never updated or deleted. A correction is a new compensating row. +- `resultingAvailable` is written at the time of the movement. Replaying the journal must reproduce the current record exactly; a divergence is a defect to investigate, not a number to overwrite. +- A manual adjustment without an `actor` is rejected. + +**Why this is in the contract rather than left to implementation.** Product Plan v3.1 §10.2 opens with the complaint that our numbers cannot be explained. A quantity you cannot reconstruct is a quantity you cannot defend to a bank, an inspector, or a seller disputing a payout. This journal is what turns "the stock says 3" into "the stock says 3, and here is every movement that made it 3." + ## 4. Lifecycle ``` @@ -121,6 +171,37 @@ Content-Type: multipart/form-data (CSV) or application/json (array) Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review. +Added 2026-08-21 (FH-2.15): the import is idempotent by SKU/external key, so re-running the same file updates rather than duplicating. A row-level error never publishes a partial result. An applied import can be rolled back **as long as none of its products have appeared on a paid order** — after that, archive rather than delete. + +## 6a. Digital fulfilment — code pools + +Added 2026-08-21 (FH-2.11). We have no digital-goods story today, and it is one table. + +```ts +type FulfillmentMode = 'manual' | 'code_pool'; + +interface DigitalCode { + id: string; + marketplaceId: string; + offerId: string; + encryptedValue: string; // see Track S §4.2 envelope + valueHash: string; // unique per (marketplaceId, offerId) + status: 'available' | 'reserved' | 'assigned' | 'revoked'; + orderLineId?: string; + createdAt: string; + assignedAt?: string; +} +``` + +Rules: +- `FulfillmentMode` is a property of the offer. `code_pool` offers derive `available` from the count of `available` codes — the two must not be maintained independently. +- `valueHash` is unique per `(marketplaceId, offerId)`, so importing the same code twice is refused by the database rather than by a check somebody can forget. +- A code moves `available → reserved` under the same conditional-write rule as §3.1, and `reserved → assigned` only on confirmed payment. +- **A code is returned to the browser only when the order is `paid`, `processing`, or `fulfilled`.** Any earlier state returns the line with an empty code list — not a masked value, not a placeholder. +- Revocation is terminal and audited (Track S §3). + +**Acceptance:** an unpaid order never yields a code, through the UI or through a direct API call with a valid session. + ## 7. Endpoints ``` diff --git a/docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md b/docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md index 237fd84..e0b476d 100644 --- a/docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md +++ b/docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md @@ -105,7 +105,26 @@ payment -> routed to exactly one payment point (Phase 1 §6.5, frozen at checkou Current flow supports QR and card only, via one custom provider integration. Adding wallets/BNPL is an explicit open business decision (not answered in Sprint 0.1) — this contract's `PaymentIntent`/`Payment` shapes from Phase 1 §6 are provider-agnostic already, so a new provider is a new adapter behind the same state machine, not a schema change. No action needed here until that business decision is made. -## 5. What the frontend will start doing once this ships +## 5. Idempotency belongs in the schema, not in a handler + +Added 2026-08-21 (FH-2.2). [Phase 1 §6.3–6.4](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) already require that a replayed webhook be a no-op and that order creation be idempotent. Both are stated as *behaviour*. Behaviour written as an `if` gets deleted by someone refactoring in eighteen months, and the failure mode is a double charge. Make the database refuse instead: + +``` +UNIQUE (payment.idempotency_key) +UNIQUE (payment_webhook_event.provider, payment_webhook_event.event_key) +``` + +Handling: + +- **Payment creation.** `Idempotency-Key` is required on the create call. If a payment already exists for that key: same order and marketplace → return the existing payment unchanged; different order or marketplace → `409`, never silently create a second one. +- **Webhook receipt.** Insert the event row *first*. A unique-violation is the duplicate signal — respond `{ accepted: true, duplicate: true }` and stop. Only a successful insert proceeds to apply the status change. Mark `processedAt` after applying, so a crash between insert and apply is visible as an unprocessed row rather than a lost event. +- **`event_key`** is the provider's event id where one exists, and `sha256(rawBody)` where it does not. A provider that sends no event id must still be replay-safe. +- **Signature verification runs against the raw request body**, before any parsing or re-serialization. Verify-after-parse is verify-nothing. +- **Poll as reconciliation, not as primary.** A scheduled job re-checks provider status for payments still `pending` within the last 24 hours and applies the result through the same state-machine path as the webhook. Transient provider failures are swallowed; the next tick retries. Never a fixed delay, never a UI-driven poll standing in for a missed webhook. + +**Acceptance:** the same provider event delivered twice completes the order once, moves stock once, and emits one notification. This is scenario 10 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test. + +## 6. What the frontend will start doing once this ships - Wire the mock `requestRefund(id)` to a real endpoint. - Build the backoffice **Payments & Finance** section (missing from admin nav today): payments, refunds, reconciliation queue, unmatched events, settlements. diff --git a/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md b/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md index c3a2505..51260c5 100644 --- a/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md +++ b/docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md @@ -179,6 +179,40 @@ POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback -- creates a NEW 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 ``` @@ -188,6 +222,16 @@ GET /api/v2/storefront/bootstrap -- resolved server-side from verified Host h - 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. diff --git a/docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md b/docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md index 38dfdeb..2a80818 100644 --- a/docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md +++ b/docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md @@ -32,6 +32,29 @@ GET /api/identity/v1/session/permissions -> { role, scopes: string[], marketpl Frontend route/action guards derive from this endpoint's response — never hardcode role logic client-side beyond hiding UI affordances (which is convenience, not security). +### 2.1 Session model + +Added 2026-08-21 (FH-2.3). §8 specifies password *change*; this specifies what a session actually is, because nothing in this series did. + +- A session token is 32 random bytes, base64url. The server stores **only its SHA-256 hash**. A database read must not yield a usable credential. +- Delivered as `HttpOnly; Secure; SameSite=Lax` cookie. Never in a response body, never in `localStorage`, never readable by script. A token in web storage is a token every XSS gets for free. +- Rows carry `expiresAt`, `revokedAt`, `ip`, `userAgent`. Admin sessions expire in 12 hours; storefront customer sessions in 30 days. +- Validation rejects on any of: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not yet enrolled. +- **One cookie name per contour** — the backoffice, the order-manager portal (§8a) and the storefront must not share a session cookie. A customer session must never satisfy an admin guard, and the way to guarantee that is for them to be different cookies checked by different guards, not the same cookie checked more carefully. +- Password change revokes every live session for that user **in the same transaction** as the password write. + +Credential storage: Argon2id, `memoryCost 65536, timeCost 3, parallelism 1`, minimum 16 characters. Second factor (TOTP) is **mandatory** for every platform- and marketplace-scope role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token plus the `otpauth://` URI, and issues no session until the factor is confirmed. An enrolment token is not a session and grants nothing else. + +### 2.2 Origin allowlist on every cookie-authenticated mutation + +Added 2026-08-21 (FH-2.4). Cookie auth without an origin check is CSRF. One hook, ahead of routing: + +- Any non-`GET`/`HEAD`/`OPTIONS` request to `/api/admin/*`, `/api/platform/*` or `/api/manager/*` whose `Origin` header is not in the configured allowlist → `403`, before the handler runs. +- CORS uses the same allowlist with `credentials: true`. Not `*`, not reflected. +- The allowlist is configuration, not code, and is per-environment. + +This is a dozen lines and it closes the entire class. It is cheap enough that there is no reason for it to arrive late. + ## 3. Audit log ```ts @@ -73,6 +96,24 @@ These are the opposite direction from the rest of §4 and follow a different rul Audit coverage (§3) extends to: `partner_credential.registered`, `partner_credential.rotated`, `partner_credential.revoked`, and every partner-initiated node write, with `actor` set to the `keyId` that signed the request. +### 4.2 Encryption envelope for stored secrets + +Added 2026-08-21 (FH-2.9). §4 says credentials live in secret storage and never leave the backend. This is the storage format, so that "encrypted" is a specification rather than an adjective. + +``` +v1... +``` + +- AES-256-GCM. 12-byte random IV per value, never reused. Key is 32 bytes, supplied by environment or secret manager, never in the repository. +- The leading version tag exists so the algorithm can be rotated without guessing at the format of existing rows. +- Decryption happens inside the service that uses the secret. A decrypted value is never placed on a DTO, never logged, never returned by any endpoint — including to a `PLATFORM_OWNER`. Backoffice shows presence, last-rotated, and a fingerprint; it does not show the value. +- Fingerprints for display or matching are `HMAC-SHA256(key, value)`, not the value truncated. +- Redirect and callback URLs are built backend-side from the tenant's verified domain and validated against an allowlist before being returned. The browser receives a URL to navigate to, never the material used to construct it. + +This covers payment provider credentials, connector credentials, bot tokens, FX source keys, and the per-tenant OAuth app secrets for [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md). + +**Acceptance:** no credential value appears in any API response, JS bundle, log line, or browser storage. The bundle half is enforced in CI by `scripts/ci/scan-bundle.sh`. + ## 5. Rate limiting ``` @@ -119,6 +160,17 @@ Invariants: - Role grants at `MARKETPLACE_ADMIN` level require step-up auth (§6). - Invited admins get their own credentials (email + set-password flow), not the shared bootstrap login — the bootstrap account is for first login only and should be rotated/retired once real admins exist. +## 8a. Order manager is a separate contour, not a narrower menu + +Added 2026-08-21 (FH-2.14). `ORDER_MANAGER` is one of the 17 roles in §1, which today implies a smaller version of the same backoffice. Make it a separate surface instead: + +- Its own URL and its own shell, its own login, and its own session cookie (§2.1). An order manager who somehow obtained a backoffice URL gets `403` from the guard, not a half-rendered admin page. +- Scope comes from **membership rows**, never from configuration. (The reference implementation we reviewed pins the manager's marketplace with an environment variable — that is the one part of it not to copy. An env string is not an access-control decision and cannot express two marketplaces.) +- Reachable data is orders, their customers, and the fulfilment actions the role is permitted. Catalog, design, domains, payment settings, platform users and platform settings are not merely hidden — the endpoints refuse. +- PII is masked in list views and revealed in detail only with the permission for it. Both the reveal and any export are audit-logged (§3, §7). + +The reason to spend a separate contour on this rather than more guards: the people who work orders all day are the largest group of accounts and the least likely to be security-trained. Reducing what their credential can reach is worth more than adding checks to what it can. + ## 9. What the frontend will start doing once this ships - Route guards and action-level permission checks across the entire backoffice — currently none exist. diff --git a/docs/context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md b/docs/context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md new file mode 100644 index 0000000..7dc1ab3 --- /dev/null +++ b/docs/context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md @@ -0,0 +1,38 @@ +--- +id: ADR-0006 +title: Harvest mechanisms from the parallel platform, keep our architecture +status: active +date: 2026-08-21 +tags: [architecture, security, contracts, platform, governance] +--- + +# ADR-0006: Harvest mechanisms from the parallel platform, keep our architecture + +## Context + +A second team built a competing platform monorepo — NestJS/Fastify API, PostgreSQL/Prisma, two Angular apps, Docker/Nginx infrastructure — sharing an older `dexarmarket` ancestor with this repo. On 2026-08-11 they received a snapshot of our code, audited it, and vendored it into their tree as `reference/parallel-frontend/`, classified as a UI/UX reference rather than production. Their handoff document ranks our work fifth of five priority sources. + +Full comparison: [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md). + +The asymmetry is real and runs both ways. They have working server-side truth: tenancy resolved from a verified `Host`, RBAC enforced per endpoint, hashed server sessions with mandatory TOTP, encrypted per-tenant payment credentials, idempotent webhooks, immutable publish revisions, WAL archiving and a restore check. We have the deeper frontend — 530 `.ts` files against 189, 158 components, 30 spec files plus Playwright e2e against their 25 unit tests and no e2e at all, Angular 22 with a clean production audit against their 21.2.18 with open high findings, and architecture governance in CI that they have no equivalent of. + +Three of their audit findings against us were still live when re-checked on 2026-08-21, and two of them were defects rather than posture: a plaintext `ip-api.com` call that mixed-content blocking had silently killed in production, and an unvalidated bank URL rendered into an iframe that most acquirers refuse to be framed in. + +## Decision + +Take the mechanisms. Do not take the architecture, and do not merge the codebases. + +- **Harvest** specific, proven mechanisms into our backend contracts under a traceable `FH-*` tag: conditional-write stock reservation, idempotency as a unique constraint, hashed server sessions with per-contour cookies, an origin allowlist on cookie-authenticated mutations, an AES-256-GCM envelope for stored secrets, signed read-only preview, revision immutability and clone semantics, an append-only inventory journal, server-side content validation, and digital code pools. +- **Reject** anything that would regress us: their Angular version, their mock service still shipping in a backoffice, their test posture, their environment-pinned manager scope, their hardcoded server IP, and their narrower section schema. +- **Keep ours where ours is better** and say so explicitly, so it does not get relitigated: our marketplace lifecycle state machine is richer than theirs, our bulk-import preview/apply flow is equivalent, our editor validation engine is stronger — it simply needs a server-side counterpart to bind. +- **Record the nine invariants** from their handoff as the acceptance gate at the head of our own backend handoff, since they are more falsifiable than anything our delivery plan had. + +## Consequences + +The backend contracts gain normative mechanism text where they previously stated intent, which raises the bar a backend built against them must clear — at the cost of more prescription than these documents originally carried. That trade is deliberate: "the webhook must be idempotent" survives one refactor, `UNIQUE (provider, event_key)` survives every refactor. + +Our repo stays frontend-only. Nothing harvested requires standing up Prisma or NestJS here; anything that would have becomes a contract line instead. Work splits across five lanes — frontend, contracts, the `@marketplaces/auth` package, infrastructure, and process — tracked in [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md). + +Adopting their invariants and their PR and release discipline as our own means our releases get slower and more evidenced. That is the intended direction. + +The organizational question this ADR does not settle: whether the two implementations converge as their backend plus our frontend. Left unchallenged, their handoff document's ranking becomes the plan of record by default.