fix(api): share base-domain API host
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Tenant subdomains route through api.<base-domain>; nginx forwards the exact storefront host derived from the validated browser origin.
This commit is contained in:
2026-08-20 16:12:37 +04:00
parent e5949c3967
commit 9cd56586fb
15 changed files with 175 additions and 65 deletions

View File

@@ -103,11 +103,26 @@ jobs:
echo "BACKEND_UPSTREAM is invalid" >&2; exit 1; echo "BACKEND_UPSTREAM is invalid" >&2; exit 1;
} }
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes" declare -A API_BASE_DOMAINS=()
for domain in $STOREFRONT_DOMAINS; do for storefront in $STOREFRONT_DOMAINS; do
[[ "$domain" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || { [[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
echo "invalid storefront domain: $domain" >&2; exit 1; echo "invalid storefront domain: $storefront" >&2; exit 1;
} }
IFS=. read -ra labels <<< "$storefront"
label_count=${#labels[@]}
take=2
tld=${labels[label_count-1]}
second_level=${labels[label_count-2]}
if (( label_count >= 3 && ${#tld} == 2 && ${#second_level} <= 3 )); then
take=3
fi
start=$((label_count - take))
base_domain=$(IFS=.; echo "${labels[*]:start}")
API_BASE_DOMAINS["$base_domain"]=1
done
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
for domain in "${!API_BASE_DOMAINS[@]}"; do
$SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \ $SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \
--domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM" --domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM"
done done

View File

@@ -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. **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: The SPA derives one API origin from the storefront's base domain:
`example.com` uses `api.example.com`, and `store1.example.com` uses `example.com`, `store1.example.com`, and `www.example.com` all use
`api.store1.example.com`. Provision DNS, TLS, reverse proxying, and CORS for `api.example.com`. Tenant identity still comes from the complete storefront
every derived API hostname. A single-label wildcard certificate does not cover host; tenant subdomains do not create additional API DNS names.
nested names such as `api.store1.example.com`.
--- ---
@@ -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/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/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`. | | `.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 | | `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` | | `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
Before deploying, point every derived API hostname at the server. For the Before deploying, point each base domain's shared API hostname at the server.
example above, DNS must resolve both `api.gorbushka.market` and For `gorbushka.market` and `store1.gorbushka.market`, only
`api.store1.example.com`. The workflow deliberately stops before release `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 activation if DNS, certificate issuance, nginx validation, or the JSON
`/bootstrap` check fails. `/bootstrap` check fails.

View File

@@ -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: 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). 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. 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. 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 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. 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. **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.

View File

@@ -6,28 +6,27 @@ and the backend. It supersedes any fixed `api.dexarmarket.ru` or same-origin
## 1. Deterministic hostname rule ## 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 | | Storefront | API origin | Bootstrap |
|---|---|---| |---|---|---|
| `example.com` | `https://api.example.com` | `https://api.example.com/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` | | `store1.example.com` | `https://api.example.com` | `https://api.example.com/bootstrap` |
| `www.example.com` | `https://api.www.example.com` | `https://api.www.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. Bootstrap, auth, legacy routes, and `/api/...` routes all use this origin.
Localhost is the only exception and continues through the local `/api` proxy. Localhost is the only exception and continues through the local `/api` proxy.
## 2. Backend changes required ## 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 1. Behind the trusted project nginx, use `X-Storefront-Host`. nginx derives it
sends the same storefront value as upstream `Host` for compatibility with the from a validated browser `Origin`, sends the same value as upstream `Host`,
currently live backend and preserves the public API hostname in and preserves the shared public API hostname in `X-Forwarded-Host`.
`X-Forwarded-Host`. 2. Do not infer a subdomain tenant from the API `Host`: `store1.example.com` and
2. Without that trusted proxy, normalize the request `Host`: lowercase, remove `example.com` intentionally share `api.example.com`. Non-browser clients must
the port, remove exactly one leading `api.` label when present, and retain provide tenant context through their authenticated server-to-server contract.
every remaining label.
3. Resolve that normalized storefront hostname through the tenant-domain 3. Resolve that normalized storefront hostname through the tenant-domain
registry. Do not infer a tenant from only the first label. registry. Do not infer a tenant from only the first label.
4. Reject unknown, disabled, or unverified domains with `403` before reading 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: Pseudo-code:
```text ```text
if request.remoteAddress is trustedProxy: require request.remoteAddress is trustedProxy
storefrontHost = lower(stripPort(request.header["X-Storefront-Host"])) storefrontHost = lower(stripPort(request.header["X-Storefront-Host"]))
else:
requestHost = lower(stripPort(request.host))
storefrontHost = removeAtMostOnePrefix(requestHost, "api.")
tenant = registry.findVerifiedDomain(storefrontHost) ?? forbidden() tenant = registry.findVerifiedDomain(storefrontHost) ?? forbidden()
request.tenant = tenant request.tenant = tenant
``` ```
## 3. CORS contract ## 3. CORS contract
For API host `api.<storefront-host>`, allow exactly: For API host `api.<base-domain>`, echo the exact validated storefront origin:
```http ```http
Access-Control-Allow-Origin: https://<storefront-host> Access-Control-Allow-Origin: https://<base-domain or registered subdomain>
Access-Control-Allow-Credentials: true Access-Control-Allow-Credentials: true
Vary: Origin Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS 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 --upstream https://127.0.0.1:445
``` ```
It creates `api.gorbushka.market`, issues/renews its certificate, configures It creates the shared `api.gorbushka.market`, issues/renews its certificate,
CORS, and proxies all paths to the backend. Upstream receives configures CORS for `gorbushka.market` and its subdomains, and proxies all paths
`Host: gorbushka.market`, `X-Forwarded-Host: api.gorbushka.market`, and to the backend. A request from `store1.gorbushka.market` reaches upstream with
`X-Storefront-Host: gorbushka.market`; the script then reloads nginx and verifies `Host` and `X-Storefront-Host` set to `store1.gorbushka.market`, while
that `/bootstrap` returns a JSON object. `X-Forwarded-Host` remains `api.gorbushka.market`.
For `store1.example.com`, both DNS and TLS must exist for `store1.example.com` requires no additional API DNS or certificate; it uses the
`api.store1.example.com`. A certificate for `*.example.com` does **not** cover same `api.example.com` certificate as the root storefront.
that two-label-deep hostname.
## 5. CI/CD contract ## 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. - Frontend bundle contains no fixed marketplace API hostname.
- Root and nested storefronts call their matching `api.<full-hostname>`. - Root and nested storefronts call the same `api.<base-domain>`.
- Unknown API hosts return `403`, not the default tenant. - Unknown storefront domains return `403`, not the default tenant.
- `/bootstrap` returns JSON and the correct tenant. - `/bootstrap` returns JSON and the correct tenant.
- API responses never return the Angular `index.html` fallback. - API responses never return the Angular `index.html` fallback.

View File

@@ -1,14 +1,17 @@
--- ---
id: ADR-0004 id: ADR-0004
title: Derive each API host from the complete storefront host title: Derive each API host from the complete storefront host
status: active status: superseded
date: 2026-08-20 date: 2026-08-20
supersedes: [] supersedes: []
tags: [architecture, multi-tenant, api, routing, dns] tags: [architecture, multi-tenant, api, routing, dns]
superseded_by: [ADR-0005]
--- ---
# ADR-0004: Derive each API host from the complete storefront host # 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 ## Context
One production bundle serves root domains and arbitrary storefront subdomains. One production bundle serves root domains and arbitrary storefront subdomains.

View File

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

View File

@@ -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-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-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-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"]}

View File

@@ -121,10 +121,17 @@ CONFIGURE_API="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/configure-api-domai
echo "ERROR: configure-api-domain.sh must be executable and next to add-domain.sh" >&2 echo "ERROR: configure-api-domain.sh must be executable and next to add-domain.sh" >&2
exit 1 exit 1
} }
"$CONFIGURE_API" --domain "$DOMAIN" --email "$EMAIL" IFS=. read -ra DOMAIN_LABELS <<< "$DOMAIN"
if [[ $WITH_WWW -eq 1 ]]; then LABEL_COUNT=${#DOMAIN_LABELS[@]}
"$CONFIGURE_API" --domain "www.$DOMAIN" --email "$EMAIL" TAKE=2
TLD=${DOMAIN_LABELS[LABEL_COUNT-1]}
SECOND_LEVEL=${DOMAIN_LABELS[LABEL_COUNT-2]}
if (( LABEL_COUNT >= 3 && ${#TLD} == 2 && ${#SECOND_LEVEL} <= 3 )); then
TAKE=3
fi fi
START=$((LABEL_COUNT - TAKE))
API_BASE_DOMAIN=$(IFS=.; echo "${DOMAIN_LABELS[*]:START}")
"$CONFIGURE_API" --domain "$API_BASE_DOMAIN" --email "$EMAIL"
echo "==> renewal timer" echo "==> renewal timer"
systemctl enable --now certbot.timer systemctl enable --now certbot.timer

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Configure api.<storefront-domain> as the TLS/CORS reverse proxy for one tenant. # Configure one shared api.<base-domain> for the base storefront and all tenant
# Idempotent. Run as root after both storefront and API DNS records resolve here. # subdomains. Idempotent. Run as root after the API DNS record resolves here.
set -euo pipefail set -euo pipefail
@@ -30,6 +30,7 @@ done
API_DOMAIN="api.$DOMAIN" API_DOMAIN="api.$DOMAIN"
CONF="/etc/nginx/sites-available/$API_DOMAIN" CONF="/etc/nginx/sites-available/$API_DOMAIN"
DOMAIN_REGEX="${DOMAIN//./\\.}"
echo "==> checking DNS for $API_DOMAIN" echo "==> checking DNS for $API_DOMAIN"
getent hosts "$API_DOMAIN" >/dev/null || { getent hosts "$API_DOMAIN" >/dev/null || {
@@ -39,7 +40,7 @@ getent hosts "$API_DOMAIN" >/dev/null || {
cat > "$CONF" <<NGINX cat > "$CONF" <<NGINX
# Managed by marketplaces configure-api-domain.sh. Manual edits are overwritten. # Managed by marketplaces configure-api-domain.sh. Manual edits are overwritten.
# Storefront $DOMAIN derives this API origin as https://$API_DOMAIN. # Storefront $DOMAIN and its tenant subdomains share https://$API_DOMAIN.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@@ -49,7 +50,11 @@ server {
error_log /var/log/nginx/$API_DOMAIN.error.log; error_log /var/log/nginx/$API_DOMAIN.error.log;
set \$cors_origin ""; set \$cors_origin "";
if (\$http_origin = "https://$DOMAIN") { set \$cors_origin \$http_origin; } set \$storefront_host "$DOMAIN";
if (\$http_origin ~* "^https://(?<allowed_storefront>([a-z0-9-]+\\.)*$DOMAIN_REGEX)$") {
set \$cors_origin \$http_origin;
set \$storefront_host \$allowed_storefront;
}
add_header Access-Control-Allow-Origin \$cors_origin always; add_header Access-Control-Allow-Origin \$cors_origin always;
add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Allow-Credentials "true" always;
@@ -62,12 +67,11 @@ server {
location / { location / {
proxy_pass $UPSTREAM; proxy_pass $UPSTREAM;
proxy_http_version 1.1; proxy_http_version 1.1;
# Keep the existing backend compatible: it already serves this tenant # Browser Origin selects the storefront tenant while every tenant under
# when the storefront Host reaches :445. The original public API host # this base domain shares one public API hostname.
# remains available in the trusted forwarding headers below. proxy_set_header Host \$storefront_host;
proxy_set_header Host $DOMAIN;
proxy_set_header X-Forwarded-Host $API_DOMAIN; proxy_set_header X-Forwarded-Host $API_DOMAIN;
proxy_set_header X-Storefront-Host $DOMAIN; proxy_set_header X-Storefront-Host \$storefront_host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;

View File

@@ -9,8 +9,9 @@ describe('ApiConfigService', () => {
beforeEach(() => { beforeEach(() => {
tenantResolver = jasmine.createSpyObj<TenantResolverService>( tenantResolver = jasmine.createSpyObj<TenantResolverService>(
'TenantResolverService', 'TenantResolverService',
['getHostname', 'getProtocol', 'getTenantKey', 'isLocalhost'] ['getHostname', 'getBaseDomain', 'getProtocol', 'getTenantKey', 'isLocalhost']
); );
tenantResolver.getBaseDomain.and.returnValue('gorbushka.market');
tenantResolver.getTenantKey.and.returnValue('gorbushka'); tenantResolver.getTenantKey.and.returnValue('gorbushka');
tenantResolver.getProtocol.and.returnValue('https:'); tenantResolver.getProtocol.and.returnValue('https:');
tenantResolver.isLocalhost.and.returnValue(false); tenantResolver.isLocalhost.and.returnValue(false);
@@ -31,16 +32,17 @@ describe('ApiConfigService', () => {
expect(service.getBaseUrl()).toBe('https://api.gorbushka.market'); expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
}); });
it('preserves every storefront subdomain in the API hostname', () => { it('uses the shared base-domain API for a tenant subdomain', () => {
tenantResolver.getHostname.and.returnValue('store1.example.com'); tenantResolver.getHostname.and.returnValue('store1.example.com');
tenantResolver.getBaseDomain.and.returnValue('example.com');
expect(service.getBaseUrl()).toBe('https://api.store1.example.com'); expect(service.getBaseUrl()).toBe('https://api.example.com');
}); });
it('preserves www like any other storefront subdomain', () => { it('uses the shared base-domain API for www', () => {
tenantResolver.getHostname.and.returnValue('www.gorbushka.market'); tenantResolver.getHostname.and.returnValue('www.gorbushka.market');
expect(service.getBaseUrl()).toBe('https://api.www.gorbushka.market'); expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
}); });
it('preserves the API namespace when targeting a tenant backend', () => { it('preserves the API namespace when targeting a tenant backend', () => {

View File

@@ -8,6 +8,7 @@ export class ApiConfigService {
getBaseUrl(): string { getBaseUrl(): string {
const hostname = this.tenantResolver.getHostname(); const hostname = this.tenantResolver.getHostname();
const baseDomain = this.tenantResolver.getBaseDomain();
const protocol = this.tenantResolver.getProtocol(); const protocol = this.tenantResolver.getProtocol();
const tenantKey = this.tenantResolver.getTenantKey(); const tenantKey = this.tenantResolver.getTenantKey();
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined; const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
@@ -25,6 +26,7 @@ export class ApiConfigService {
} else if (apiTemplate && hostname) { } else if (apiTemplate && hostname) {
url = apiTemplate url = apiTemplate
.replace('{protocol}', protocol) .replace('{protocol}', protocol)
.replace('{baseDomain}', baseDomain)
.replace('{hostname}', hostname) .replace('{hostname}', hostname)
.replace('{tenant}', tenantKey); .replace('{tenant}', tenantKey);
} }

View File

@@ -0,0 +1,33 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { TenantResolverService } from './tenant-resolver.service';
describe('TenantResolverService', () => {
function resolveBaseDomain(hostname: string): string {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
TenantResolverService,
{
provide: DOCUMENT,
useValue: { location: { hostname, protocol: 'https:' } }
}
]
});
return TestBed.inject(TenantResolverService).getBaseDomain();
}
it('keeps a root storefront domain', () => {
expect(resolveBaseDomain('example.com')).toBe('example.com');
});
it('removes tenant and www subdomains from the API domain', () => {
expect(resolveBaseDomain('store1.example.com')).toBe('example.com');
expect(resolveBaseDomain('www.example.com')).toBe('example.com');
});
it('keeps a country-code second-level domain', () => {
expect(resolveBaseDomain('store1.example.co.uk')).toBe('example.co.uk');
});
});

View File

@@ -15,6 +15,20 @@ export class TenantResolverService {
return this.document?.location?.protocol ?? 'https:'; return this.document?.location?.protocol ?? 'https:';
} }
getBaseDomain(): string {
const segments = this.getHostname().split('.').filter(Boolean);
if (segments.length <= 2) {
return segments.join('.');
}
const topLevelDomain = segments.at(-1) ?? '';
const secondLevelDomain = segments.at(-2) ?? '';
const usesCountryCodeSecondLevel =
topLevelDomain.length === 2 && secondLevelDomain.length <= 3;
return segments.slice(usesCountryCodeSecondLevel ? -3 : -2).join('.');
}
isLocalhost(): boolean { isLocalhost(): boolean {
const hostname = this.getHostname(); const hostname = this.getHostname();
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';

View File

@@ -4,7 +4,7 @@ export const environment = {
useMockBootstrapOnLocal: false, useMockBootstrapOnLocal: false,
fallbackTenantKey: 'default', fallbackTenantKey: 'default',
localhostApiUrl: '/api', localhostApiUrl: '/api',
tenantApiTemplate: '{protocol}//api.{hostname}', tenantApiTemplate: '{protocol}//api.{baseDomain}',
tenantApiBaseUrls: {}, tenantApiBaseUrls: {},
brandName: 'Marketplace', brandName: 'Marketplace',
brandFullName: 'Marketplace', brandFullName: 'Marketplace',

View File

@@ -5,7 +5,7 @@ export const environment = {
useMockBootstrapOnLocal: true, useMockBootstrapOnLocal: true,
fallbackTenantKey: 'default', fallbackTenantKey: 'default',
localhostApiUrl: '/api', localhostApiUrl: '/api',
tenantApiTemplate: '{protocol}//api.{hostname}', tenantApiTemplate: '{protocol}//api.{baseDomain}',
tenantApiBaseUrls: {}, tenantApiBaseUrls: {},
brandName: 'Marketplace', brandName: 'Marketplace',
brandFullName: 'Marketplace', brandFullName: 'Marketplace',