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>
12 KiB
Track S Backend Contract — RBAC, Audit, Secrets, Rate Limiting
Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Track S. Covers plan §4.4, §10.
Status: ready to build. Gates the launch — this is the single most serious security gap identified in this session's audit. Today the admin role model is decorative: AdminRole and permissions exist as types, but nothing gates any button, page, or action anywhere in the app. Any authenticated admin has full access.
1. Roles (17 total, 3 scopes, per plan §4.4)
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
type MarketplaceRole =
| 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER'
| 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
type SellerRole =
| 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
| 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
SellerRole is already specified in Phase 5's contract §4 — this doc adds the platform and marketplace scopes around it.
2. Enforcement (backend-side, non-negotiable)
Every /api/admin/v2/* and /api/platform/v1/* endpoint must check (role, tenantScope) against the acting user's session — before touching data, not as a post-hoc filter. tenant scope here means: a MARKETPLACE_ADMIN for marketplace A must get a 403 (not an empty result) querying marketplace B's data, never a silently-scoped response that looks like "there's just nothing here."
GET /api/identity/v1/session/permissions -> { role, scopes: string[], marketplaceIds: string[] }
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=Laxcookie. Never in a response body, never inlocalStorage, 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,
revokedAtset, pastexpiresAt, 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/OPTIONSrequest to/api/admin/*,/api/platform/*or/api/manager/*whoseOriginheader 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
interface AuditEvent {
id: string;
actor: string;
action: string; // e.g. 'role.changed', 'offer.price_updated', 'refund.approved'
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
reason?: string;
occurredAt: string;
ip?: string;
}
Mandatory coverage (plan §10.1): permission changes, seller status changes, catalog moderation actions, price changes, payment/refund actions, manual order overrides, integration credential changes, production launch actions.
GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=
4. Secrets
All provider/connector credentials (payment providers, external marketplace connectors, VK/MAX/Telegram bot tokens, FX source keys) live in dedicated secret storage, referenced by opaque credentialRef strings in every other contract in this series — never returned in any API response body, never logged in plaintext.
4.1 Partner credentials (inbound)
Added 2026-08-18. Partners calling our API authenticate with signed requests, not bearer tokens. Full contract: PARTNER-PROVISIONING-API-CONTRACT.md §6.
These are the opposite direction from the rest of §4 and follow a different rule:
- We hold only the partner's public key. The private key is generated by the partner and never transmitted to us, never accepted by any endpoint, never logged. There is nothing to store in secret storage on our side.
- Authority is node-scoped: a credential may act on its
scopeNodeIdand that node's descendants, nothing above or beside it. This is a separate axis from the 17 roles in §1 — partner credentials never map onto a human role, and a partner credential can never be granted an admin role. TESTandLIVEcredentials are disjoint. ATESTkey addressing aLIVEnode is403.- Rotation runs with a bounded overlap window (default 7 days) during which both keys verify. Revocation is immediate and irreversible.
- A credential can never widen its own scope or register another credential at a wider scope.
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.
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
429 response: { error: { code: 'RATE_LIMITED', retryAfterSeconds: number } }
Applies to storefront/auth/provider endpoints. Frontend currently has zero 429 handling anywhere — see BACKEND-API-REFERENCE.md §5 for the full error-envelope contract this should follow.
Partner API limits are per partnerId, by tier, with the tier set on PartnerProfile. Published in the partner OpenAPI spec — a partner must be able to read its own limit rather than discover it by getting 429.
6. Step-up authentication
Required before: bank/payment detail changes (Phase 5 §5), production launch (Phase 9 §3 step 8), role grants at PLATFORM_OWNER/MARKETPLACE_ADMIN level, and any manual financial override (refund approval outside normal flow, price override on a live order).
7. PII minimization
Customer/seller PII is exposed only to roles that need it for their scope (e.g. FINANCE_VIEWER sees payout totals, not raw bank account numbers unless FINANCE_MANAGER+). Export endpoints (GET .../export) are themselves audit-logged actions per §3.
8. Initial admin provisioning & self-service admin management
Each marketplace ships with one bootstrap MARKETPLACE_ADMIN account, seeded at provisioning time (Phase 9 launch step):
login= marketplace slug (projectName)password={projectName}2026$, flaggedmustChangePassword: true- Login succeeds but every non-auth request 403s with
PASSWORD_CHANGE_REQUIREDuntil password is changed.
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
A MARKETPLACE_ADMIN can then provision sub-admins scoped to their own marketplace only — mirrors the seller-team invite pattern in Phase 5 (POST /api/seller/v1/team/invite):
POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }
GET /api/admin/v2/team?marketplaceId=
PATCH /api/admin/v2/team/{userId} { role }
DELETE /api/admin/v2/team/{userId}
Invariants:
rolemust be one of theMarketplaceRoleset (§1) — neverPlatformRole. Backend rejects any attempt to grant a platform-scope role through this endpoint (403 SCOPE_ESCALATION_DENIED).marketplaceIdis forced server-side to the caller's own tenant scope — request body value is ignored/validated, never trusted.- Every invite/role-change/removal is an audit-logged action (§3,
action: 'admin_team.invited' | 'admin_team.role_changed' | 'admin_team.removed'). - Role grants at
MARKETPLACE_ADMINlevel 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
403from 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.
- Backoffice Audit & Security section (missing from admin nav today): role changes, sensitive actions, login/security events, exports.
- Reconcile
AdminRole(already de-duplicated to one canonical type this session) against the real 17-role table from §1. - 429 interceptor + retry-after UI.