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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.<base64url iv>.<base64url authTag>.<base64url ciphertext>
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user