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:
sdarbinyan
2026-08-21 11:12:05 +04:00
parent e8fc8480fe
commit f9e09b1757
9 changed files with 353 additions and 38 deletions

View File

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