From 9cd56586fb551a9d6164040344af3bce3b47d41a Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 20 Aug 2026 16:12:37 +0400 Subject: [PATCH] fix(api): share base-domain API host Tenant subdomains route through api.; nginx forwards the exact storefront host derived from the validated browser origin. --- .github/workflows/deploy.yml | 23 +++++++-- docs/DEPLOYMENT.md | 18 +++---- docs/backend/BACKEND-HANDOFF.md | 6 +-- docs/backend/TENANT-API-DOMAIN-HANDOFF.md | 51 +++++++++---------- ...04-derive-api-host-from-storefront-host.md | 5 +- ...e-api-host-across-storefront-subdomains.md | 35 +++++++++++++ .../features/platform-vision/FACTS.jsonl | 2 +- scripts/deploy/add-domain.sh | 13 +++-- scripts/deploy/configure-api-domain.sh | 22 ++++---- .../core/config/api-config.service.spec.ts | 12 +++-- src/app/core/config/api-config.service.ts | 2 + .../config/tenant-resolver.service.spec.ts | 33 ++++++++++++ .../core/config/tenant-resolver.service.ts | 14 +++++ src/environments/environment.production.ts | 2 +- src/environments/environment.ts | 2 +- 15 files changed, 175 insertions(+), 65 deletions(-) create mode 100644 docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md create mode 100644 src/app/core/config/tenant-resolver.service.spec.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae91d83..dc34d3c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,11 +103,26 @@ jobs: echo "BACKEND_UPSTREAM is invalid" >&2; exit 1; } - SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes" - for domain in $STOREFRONT_DOMAINS; do - [[ "$domain" =~ ^[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; + declare -A API_BASE_DOMAINS=() + for storefront in $STOREFRONT_DOMAINS; do + [[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || { + 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 \ --domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM" done diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 80a0c91..bdb04b8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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.` TLS, exact CORS, backend proxy, and JSON bootstrap verification. | +| `scripts/deploy/configure-api-domain.sh` | Configure shared `api.` 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. diff --git a/docs/backend/BACKEND-HANDOFF.md b/docs/backend/BACKEND-HANDOFF.md index 0dd56b6..0949f1a 100644 --- a/docs/backend/BACKEND-HANDOFF.md +++ b/docs/backend/BACKEND-HANDOFF.md @@ -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.` as the API alias of ``; 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. diff --git a/docs/backend/TENANT-API-DOMAIN-HANDOFF.md b/docs/backend/TENANT-API-DOMAIN-HANDOFF.md index a0be7f5..cc4d7b6 100644 --- a/docs/backend/TENANT-API-DOMAIN-HANDOFF.md +++ b/docs/backend/TENANT-API-DOMAIN-HANDOFF.md @@ -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.`: +For every request received publicly on the shared `api.`: -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.`: 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.`, allow exactly: +For API host `api.`, echo the exact validated storefront origin: ```http -Access-Control-Allow-Origin: https:// +Access-Control-Allow-Origin: https:// 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.`. -- Unknown API hosts return `403`, not the default tenant. +- Root and nested storefronts call the same `api.`. +- 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. diff --git a/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md b/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md index 71164ad..c206e29 100644 --- a/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md +++ b/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md @@ -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. diff --git a/docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md b/docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md new file mode 100644 index 0000000..57139c1 --- /dev/null +++ b/docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md @@ -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. diff --git a/docs/context/features/platform-vision/FACTS.jsonl b/docs/context/features/platform-vision/FACTS.jsonl index 0f3a1f9..c496917 100644 --- a/docs/context/features/platform-vision/FACTS.jsonl +++ b/docs/context/features/platform-vision/FACTS.jsonl @@ -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"]} diff --git a/scripts/deploy/add-domain.sh b/scripts/deploy/add-domain.sh index 49719de..bf7b6a1 100755 --- a/scripts/deploy/add-domain.sh +++ b/scripts/deploy/add-domain.sh @@ -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 exit 1 } -"$CONFIGURE_API" --domain "$DOMAIN" --email "$EMAIL" -if [[ $WITH_WWW -eq 1 ]]; then - "$CONFIGURE_API" --domain "www.$DOMAIN" --email "$EMAIL" +IFS=. read -ra DOMAIN_LABELS <<< "$DOMAIN" +LABEL_COUNT=${#DOMAIN_LABELS[@]} +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 +START=$((LABEL_COUNT - TAKE)) +API_BASE_DOMAIN=$(IFS=.; echo "${DOMAIN_LABELS[*]:START}") +"$CONFIGURE_API" --domain "$API_BASE_DOMAIN" --email "$EMAIL" echo "==> renewal timer" systemctl enable --now certbot.timer diff --git a/scripts/deploy/configure-api-domain.sh b/scripts/deploy/configure-api-domain.sh index 9fa531f..409bca4 100755 --- a/scripts/deploy/configure-api-domain.sh +++ b/scripts/deploy/configure-api-domain.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Configure api. as the TLS/CORS reverse proxy for one tenant. -# Idempotent. Run as root after both storefront and API DNS records resolve here. +# Configure one shared api. for the base storefront and all tenant +# subdomains. Idempotent. Run as root after the API DNS record resolves here. set -euo pipefail @@ -30,6 +30,7 @@ done API_DOMAIN="api.$DOMAIN" CONF="/etc/nginx/sites-available/$API_DOMAIN" +DOMAIN_REGEX="${DOMAIN//./\\.}" echo "==> checking DNS for $API_DOMAIN" getent hosts "$API_DOMAIN" >/dev/null || { @@ -39,7 +40,7 @@ getent hosts "$API_DOMAIN" >/dev/null || { cat > "$CONF" <([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-Credentials "true" always; @@ -62,12 +67,11 @@ server { location / { proxy_pass $UPSTREAM; proxy_http_version 1.1; - # Keep the existing backend compatible: it already serves this tenant - # when the storefront Host reaches :445. The original public API host - # remains available in the trusted forwarding headers below. - proxy_set_header Host $DOMAIN; + # Browser Origin selects the storefront tenant while every tenant under + # this base domain shares one public API hostname. + proxy_set_header Host \$storefront_host; 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-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; diff --git a/src/app/core/config/api-config.service.spec.ts b/src/app/core/config/api-config.service.spec.ts index 272a4bd..b16a03f 100644 --- a/src/app/core/config/api-config.service.spec.ts +++ b/src/app/core/config/api-config.service.spec.ts @@ -9,8 +9,9 @@ describe('ApiConfigService', () => { beforeEach(() => { tenantResolver = jasmine.createSpyObj( 'TenantResolverService', - ['getHostname', 'getProtocol', 'getTenantKey', 'isLocalhost'] + ['getHostname', 'getBaseDomain', 'getProtocol', 'getTenantKey', 'isLocalhost'] ); + tenantResolver.getBaseDomain.and.returnValue('gorbushka.market'); tenantResolver.getTenantKey.and.returnValue('gorbushka'); tenantResolver.getProtocol.and.returnValue('https:'); tenantResolver.isLocalhost.and.returnValue(false); @@ -31,16 +32,17 @@ describe('ApiConfigService', () => { 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.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'); - 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', () => { diff --git a/src/app/core/config/api-config.service.ts b/src/app/core/config/api-config.service.ts index f6340c8..82cfffa 100644 --- a/src/app/core/config/api-config.service.ts +++ b/src/app/core/config/api-config.service.ts @@ -8,6 +8,7 @@ export class ApiConfigService { getBaseUrl(): string { const hostname = this.tenantResolver.getHostname(); + const baseDomain = this.tenantResolver.getBaseDomain(); const protocol = this.tenantResolver.getProtocol(); const tenantKey = this.tenantResolver.getTenantKey(); const tenantMap = (environment as any).tenantApiBaseUrls as Record | undefined; @@ -25,6 +26,7 @@ export class ApiConfigService { } else if (apiTemplate && hostname) { url = apiTemplate .replace('{protocol}', protocol) + .replace('{baseDomain}', baseDomain) .replace('{hostname}', hostname) .replace('{tenant}', tenantKey); } diff --git a/src/app/core/config/tenant-resolver.service.spec.ts b/src/app/core/config/tenant-resolver.service.spec.ts new file mode 100644 index 0000000..b6ffbc0 --- /dev/null +++ b/src/app/core/config/tenant-resolver.service.spec.ts @@ -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'); + }); +}); diff --git a/src/app/core/config/tenant-resolver.service.ts b/src/app/core/config/tenant-resolver.service.ts index 8282363..1c27ce8 100644 --- a/src/app/core/config/tenant-resolver.service.ts +++ b/src/app/core/config/tenant-resolver.service.ts @@ -15,6 +15,20 @@ export class TenantResolverService { 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 { const hostname = this.getHostname(); return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index de91bab..15857c2 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -4,7 +4,7 @@ export const environment = { useMockBootstrapOnLocal: false, fallbackTenantKey: 'default', localhostApiUrl: '/api', - tenantApiTemplate: '{protocol}//api.{hostname}', + tenantApiTemplate: '{protocol}//api.{baseDomain}', tenantApiBaseUrls: {}, brandName: 'Marketplace', brandFullName: 'Marketplace', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 62a7188..d561417 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -5,7 +5,7 @@ export const environment = { useMockBootstrapOnLocal: true, fallbackTenantKey: 'default', localhostApiUrl: '/api', - tenantApiTemplate: '{protocol}//api.{hostname}', + tenantApiTemplate: '{protocol}//api.{baseDomain}', tenantApiBaseUrls: {}, brandName: 'Marketplace', brandFullName: 'Marketplace',