Merge branch 'B2B'
This commit is contained in:
@@ -7,11 +7,10 @@ defaults to `https://127.0.0.1:445`).
|
||||
|
||||
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-HANDOFF.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
|
||||
|
||||
The SPA derives its API origin from the complete storefront hostname:
|
||||
`example.com` uses `api.example.com`, and `store1.example.com` uses
|
||||
`api.store1.example.com`. Provision DNS, TLS, reverse proxying, and CORS for
|
||||
every derived API hostname. A single-label wildcard certificate does not cover
|
||||
nested names such as `api.store1.example.com`.
|
||||
The SPA derives one API origin from the storefront's base domain:
|
||||
`example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`api.example.com`. Tenant identity still comes from the complete storefront
|
||||
host; tenant subdomains do not create additional API DNS names.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +20,7 @@ nested names such as `api.store1.example.com`.
|
||||
|---|---|
|
||||
| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. |
|
||||
| `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. |
|
||||
| `scripts/deploy/configure-api-domain.sh` | Configure `api.<full storefront host>` TLS, exact CORS, backend proxy, and JSON bootstrap verification. |
|
||||
| `scripts/deploy/configure-api-domain.sh` | Configure shared `api.<base domain>` TLS, storefront-origin CORS, backend proxy, and JSON bootstrap verification. |
|
||||
| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. |
|
||||
|
||||
---
|
||||
@@ -93,9 +92,10 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp
|
||||
| `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
|
||||
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
|
||||
|
||||
Before deploying, point every derived API hostname at the server. For the
|
||||
example above, DNS must resolve both `api.gorbushka.market` and
|
||||
`api.store1.example.com`. The workflow deliberately stops before release
|
||||
Before deploying, point each base domain's shared API hostname at the server.
|
||||
For `gorbushka.market` and `store1.gorbushka.market`, only
|
||||
`api.gorbushka.market` is required. The workflow deduplicates
|
||||
`STOREFRONT_DOMAINS` by base domain and deliberately stops before release
|
||||
activation if DNS, certificate issuance, nginx validation, or the JSON
|
||||
`/bootstrap` check fails.
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ hostname normalization, CORS, nginx, TLS, CI secrets, and acceptance checks.
|
||||
One deployed bundle serves **every customer domain**. There is no per-tenant build. The chain is:
|
||||
|
||||
1. [`TenantResolverService`](../../src/app/core/config/tenant-resolver.service.ts) reads the complete current browser hostname and protocol (localhost still uses the development proxy).
|
||||
2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) prefixes that hostname with `api.`: `example.com` becomes `api.example.com`; `store1.example.com` becomes `api.store1.example.com`.
|
||||
2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) uses one API host per base domain: both `example.com` and `store1.example.com` use `api.example.com`.
|
||||
3. `ApiBootstrapProvider`, auth, legacy API calls, and versioned `/api/...` calls all use that same base.
|
||||
4. Each derived API hostname owns DNS, TLS, CORS, and a reverse proxy to the backend.
|
||||
5. Backend tenant lookup recognizes `api.<storefront-host>` as the API alias of `<storefront-host>`; frontend nginx remains `default_server` / `server_name _`, so any attached storefront domain receives the same bundle.
|
||||
4. Each base domain owns one API DNS/TLS/reverse-proxy entry. nginx validates the browser origin and forwards the complete storefront hostname as `X-Storefront-Host`.
|
||||
5. Backend tenant lookup uses that trusted storefront hostname, not the shared API `Host`; frontend nginx remains `default_server` / `server_name _`, so any attached storefront domain receives the same bundle.
|
||||
|
||||
**What this means for you:** the bootstrap endpoint is the single most important thing to build after auth. Every request must be tenant-scoped server-side, and a tenant must never be able to read another tenant's data — return `403`, not an empty result (see [TRACK-S §2](TRACK-S-SECURITY-RBAC-CONTRACT.md)). The frontend supplies the tenant identity from the hostname; the backend must treat that as an untrusted hint and derive real scope from the authenticated session.
|
||||
|
||||
|
||||
@@ -6,28 +6,27 @@ and the backend. It supersedes any fixed `api.dexarmarket.ru` or same-origin
|
||||
|
||||
## 1. Deterministic hostname rule
|
||||
|
||||
The frontend prefixes the **complete** storefront hostname with `api.`:
|
||||
The frontend uses one API hostname per **base domain**:
|
||||
|
||||
| Storefront | API origin | Bootstrap |
|
||||
|---|---|---|
|
||||
| `example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
| `store1.example.com` | `https://api.store1.example.com` | `https://api.store1.example.com/bootstrap` |
|
||||
| `www.example.com` | `https://api.www.example.com` | `https://api.www.example.com/bootstrap` |
|
||||
| `store1.example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
| `www.example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
|
||||
|
||||
Bootstrap, auth, legacy routes, and `/api/...` routes all use this origin.
|
||||
Localhost is the only exception and continues through the local `/api` proxy.
|
||||
|
||||
## 2. Backend changes required
|
||||
|
||||
For every request received publicly on `api.<storefront-host>`:
|
||||
For every request received publicly on the shared `api.<base-domain>`:
|
||||
|
||||
1. Behind the trusted project nginx, use `X-Storefront-Host`. nginx deliberately
|
||||
sends the same storefront value as upstream `Host` for compatibility with the
|
||||
currently live backend and preserves the public API hostname in
|
||||
`X-Forwarded-Host`.
|
||||
2. Without that trusted proxy, normalize the request `Host`: lowercase, remove
|
||||
the port, remove exactly one leading `api.` label when present, and retain
|
||||
every remaining label.
|
||||
1. Behind the trusted project nginx, use `X-Storefront-Host`. nginx derives it
|
||||
from a validated browser `Origin`, sends the same value as upstream `Host`,
|
||||
and preserves the shared public API hostname in `X-Forwarded-Host`.
|
||||
2. Do not infer a subdomain tenant from the API `Host`: `store1.example.com` and
|
||||
`example.com` intentionally share `api.example.com`. Non-browser clients must
|
||||
provide tenant context through their authenticated server-to-server contract.
|
||||
3. Resolve that normalized storefront hostname through the tenant-domain
|
||||
registry. Do not infer a tenant from only the first label.
|
||||
4. Reject unknown, disabled, or unverified domains with `403` before reading
|
||||
@@ -41,21 +40,18 @@ For every request received publicly on `api.<storefront-host>`:
|
||||
Pseudo-code:
|
||||
|
||||
```text
|
||||
if request.remoteAddress is trustedProxy:
|
||||
storefrontHost = lower(stripPort(request.header["X-Storefront-Host"]))
|
||||
else:
|
||||
requestHost = lower(stripPort(request.host))
|
||||
storefrontHost = removeAtMostOnePrefix(requestHost, "api.")
|
||||
require request.remoteAddress is trustedProxy
|
||||
storefrontHost = lower(stripPort(request.header["X-Storefront-Host"]))
|
||||
tenant = registry.findVerifiedDomain(storefrontHost) ?? forbidden()
|
||||
request.tenant = tenant
|
||||
```
|
||||
|
||||
## 3. CORS contract
|
||||
|
||||
For API host `api.<storefront-host>`, allow exactly:
|
||||
For API host `api.<base-domain>`, echo the exact validated storefront origin:
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Origin: https://<storefront-host>
|
||||
Access-Control-Allow-Origin: https://<base-domain or registered subdomain>
|
||||
Access-Control-Allow-Credentials: true
|
||||
Vary: Origin
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
@@ -77,15 +73,14 @@ scripts/deploy/configure-api-domain.sh \
|
||||
--upstream https://127.0.0.1:445
|
||||
```
|
||||
|
||||
It creates `api.gorbushka.market`, issues/renews its certificate, configures
|
||||
CORS, and proxies all paths to the backend. Upstream receives
|
||||
`Host: gorbushka.market`, `X-Forwarded-Host: api.gorbushka.market`, and
|
||||
`X-Storefront-Host: gorbushka.market`; the script then reloads nginx and verifies
|
||||
that `/bootstrap` returns a JSON object.
|
||||
It creates the shared `api.gorbushka.market`, issues/renews its certificate,
|
||||
configures CORS for `gorbushka.market` and its subdomains, and proxies all paths
|
||||
to the backend. A request from `store1.gorbushka.market` reaches upstream with
|
||||
`Host` and `X-Storefront-Host` set to `store1.gorbushka.market`, while
|
||||
`X-Forwarded-Host` remains `api.gorbushka.market`.
|
||||
|
||||
For `store1.example.com`, both DNS and TLS must exist for
|
||||
`api.store1.example.com`. A certificate for `*.example.com` does **not** cover
|
||||
that two-label-deep hostname.
|
||||
`store1.example.com` requires no additional API DNS or certificate; it uses the
|
||||
same `api.example.com` certificate as the root storefront.
|
||||
|
||||
## 5. CI/CD contract
|
||||
|
||||
@@ -118,7 +113,7 @@ curl -i -X OPTIONS https://api.example.com/bootstrap \
|
||||
```
|
||||
|
||||
- Frontend bundle contains no fixed marketplace API hostname.
|
||||
- Root and nested storefronts call their matching `api.<full-hostname>`.
|
||||
- Unknown API hosts return `403`, not the default tenant.
|
||||
- Root and nested storefronts call the same `api.<base-domain>`.
|
||||
- Unknown storefront domains return `403`, not the default tenant.
|
||||
- `/bootstrap` returns JSON and the correct tenant.
|
||||
- API responses never return the Angular `index.html` fallback.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
---
|
||||
id: ADR-0004
|
||||
title: Derive each API host from the complete storefront host
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-08-20
|
||||
supersedes: []
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
superseded_by: [ADR-0005]
|
||||
---
|
||||
|
||||
# ADR-0004: Derive each API host from the complete storefront host
|
||||
|
||||
> Superseded by [ADR-0005](ADR-0005-share-api-host-across-storefront-subdomains.md).
|
||||
|
||||
## Context
|
||||
|
||||
One production bundle serves root domains and arbitrary storefront subdomains.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: ADR-0005
|
||||
title: Share one API host across storefront subdomains
|
||||
status: active
|
||||
date: 2026-08-20
|
||||
supersedes: [ADR-0004]
|
||||
tags: [architecture, multi-tenant, api, routing, dns]
|
||||
---
|
||||
|
||||
# ADR-0005: Share one API host across storefront subdomains
|
||||
|
||||
## Context
|
||||
|
||||
One frontend bundle serves a base storefront domain and tenant subdomains. The
|
||||
API is shared at the base-domain level; a tenant subdomain must not create a
|
||||
nested API hostname.
|
||||
|
||||
## Decision
|
||||
|
||||
- `example.com`, `store1.example.com`, and `www.example.com` all use
|
||||
`https://api.example.com`.
|
||||
- The complete storefront hostname remains the tenant hint. nginx validates the
|
||||
browser Origin and forwards that hostname as `X-Storefront-Host`.
|
||||
- Backend tenant lookup trusts that header only from the known proxy, verifies
|
||||
it against the domain registry, and binds authenticated sessions to the same
|
||||
tenant.
|
||||
- Localhost continues through `/api`. `tenantApiBaseUrls` remains available for
|
||||
public-suffix or custom-domain exceptions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tenant subdomains need no extra API DNS records or certificates. CORS must echo
|
||||
the exact allowed storefront origin, while unknown or disabled domains still
|
||||
receive `403` from the backend. The shared API `Host` alone cannot identify a
|
||||
subdomain tenant.
|
||||
@@ -11,4 +11,4 @@
|
||||
{"id":"PV-20260818T104300Z-b3c4","subject":"RoutingContext","predicate":"is-required-on","object":"CheckoutSession, PaymentIntent, Payment, Refund and ReconciliationRecord; frozen at checkout-session creation and immutable thereafter, so a payment is always attributable to exactly one payment point","src":["docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:43:00Z","confidence":"high","tags":["payments","reconciliation","contract"]}
|
||||
{"id":"PV-20260818T104400Z-d9e2","subject":"partner-api-credentials","predicate":"are-scoped-by","object":"a single node whose subtree defines authority; we hold only the partner-generated public key, rotation runs on a bounded overlap window and revocation is immediate and irreversible","src":["docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md","docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:44:00Z","confidence":"high","tags":["security","credentials","partner"]}
|
||||
{"id":"PV-20260818T104500Z-a6f7","subject":"checkout-payment-methods","predicate":"already-support","object":"both qr and card end to end in src/app/pages/cart/cart.component.ts (separate create paths and separate status pollers); card is not an outstanding gap","src":["src/app/pages/cart/cart.component.ts","src/app/services/api.service.ts"],"status":"active","kind":"implemented","updated_at":"2026-08-18T10:45:00Z","confidence":"high","tags":["payments","frontend"]}
|
||||
{"id":"PV-20260820T095500Z-b17e","subject":"tenant-api-routing","predicate":"is-decided-to-use","object":"a runtime-derived API origin that prefixes the complete storefront hostname with api.; example.com maps to api.example.com and store1.example.com maps to api.store1.example.com for bootstrap, auth, legacy, and versioned endpoints","src":["docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md","src/app/core/config/api-config.service.ts"],"status":"active","kind":"decision","updated_at":"2026-08-20T10:31:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","dns"]}
|
||||
{"id":"PV-20260820T095500Z-b17e","subject":"tenant-api-routing","predicate":"is-decided-to-use","object":"one runtime-derived API origin per base domain; example.com and store1.example.com both map to api.example.com for bootstrap, auth, legacy, and versioned endpoints","src":["docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md","src/app/core/config/api-config.service.ts"],"status":"active","kind":"decision","updated_at":"2026-08-20T16:00:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","dns"]}
|
||||
|
||||
Reference in New Issue
Block a user