diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e824e40..15f63bb 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,12 +1,14 @@ # Deployment — server provisioning, CD, TLS -Frontend only. The backend service (`:8080`) is a separate developer's responsibility; nginx proxies the same-origin `/backend/` gateway (and the legacy direct `/api/` route) to it and will `502` until it exists. +Frontend only. The backend service (`:8080`) is a separate developer's responsibility. API hostnames are separate reverse proxies and will return `502` until their upstream exists. **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. -All browser API traffic uses `{origin}/backend/...`. nginx removes `/backend/` -before forwarding and preserves `Host`; do not replace this with a hardcoded API -domain or a direct backend port, because that breaks custom domains and CORS. +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`. --- diff --git a/docs/backend/BACKEND-HANDOFF.md b/docs/backend/BACKEND-HANDOFF.md index 06ac690..d245a27 100644 --- a/docs/backend/BACKEND-HANDOFF.md +++ b/docs/backend/BACKEND-HANDOFF.md @@ -10,11 +10,11 @@ Single entry point for a backend developer picking this up cold. Written 2026-08 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 current browser hostname/origin (localhost still uses the development proxy). -2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) resolves one same-origin API base: `{origin}/backend`. +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`. 3. `ApiBootstrapProvider`, auth, legacy API calls, and versioned `/api/...` calls all use that same base. -4. nginx owns `/backend/`, strips that prefix, proxies to the backend, and preserves the original `Host` so the backend can resolve the tenant server-side. -5. nginx is `default_server` / `server_name _`, so any domain pointed at the server IP gets the same bundle and self-resolves. +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. **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/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md b/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md new file mode 100644 index 0000000..71164ad --- /dev/null +++ b/docs/context/adrs/ADR-0004-derive-api-host-from-storefront-host.md @@ -0,0 +1,52 @@ +--- +id: ADR-0004 +title: Derive each API host from the complete storefront host +status: active +date: 2026-08-20 +supersedes: [] +tags: [architecture, multi-tenant, api, routing, dns] +--- + +# ADR-0004: Derive each API host from the complete storefront host + +## Context + +One production bundle serves root domains and arbitrary storefront subdomains. +The old bundle embedded `api.dexarmarket.ru`, while an earlier correction used +a same-origin `/backend` gateway. Neither expresses the required domain rule: +each storefront has a corresponding API hostname derived from its full host. + +Examples: + +- `example.com` uses `api.example.com`. +- `store1.example.com` uses `api.store1.example.com`. + +Bootstrap, auth, legacy endpoints, and versioned endpoints must not use +different base-host selection rules. + +## Decision + +At runtime the frontend prefixes the complete browser hostname with `api.` and +keeps the browser protocol: `{protocol}//api.{hostname}`. + +- Bootstrap loads from `https://api.{hostname}/bootstrap`. +- Auth receives the same derived base through `AUTH_API_URL`. +- Legacy endpoints append their existing paths to that base. +- Versioned `/api/...` endpoints retain the `/api` prefix. +- Localhost and loopback continue to use the local `/api` development proxy. +- An explicit `tenantApiBaseUrls` entry may override the convention for an + exceptional host, without changing the shared bundle. + +The complete hostname is preserved. In particular, `www.example.com` maps to +`api.www.example.com`; no label is stripped or interpreted by the frontend. + +## Consequences + +One artifact works on root domains and nested storefront subdomains without a +tenant allowlist or per-domain build. Every API hostname must have DNS, TLS, a +working reverse proxy, and CORS configured for its corresponding storefront. + +A wildcard such as `*.example.com` does not cover the multi-label hostname +`api.store1.example.com`; nested API names need explicit certificates/DNS or a +certificate and routing strategy that covers that depth. Backend tenant lookup +must recognize `api.` as the API alias of ``. diff --git a/docs/context/adrs/ADR-0004-route-every-tenant-api-through-a-same-origin-gateway.md b/docs/context/adrs/ADR-0004-route-every-tenant-api-through-a-same-origin-gateway.md deleted file mode 100644 index d962cdc..0000000 --- a/docs/context/adrs/ADR-0004-route-every-tenant-api-through-a-same-origin-gateway.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -id: ADR-0004 -title: Route every tenant API through a same-origin gateway -status: active -date: 2026-08-20 -supersedes: [] -tags: [architecture, multi-tenant, api, routing, nginx] ---- - -# ADR-0004: Route every tenant API through a same-origin gateway - -## Context - -The production bundle embedded `api.dexarmarket.ru` and constructed unknown tenant -URLs as `https://{tenant}.api.dexarmarket.ru:445`. Bootstrap used a different -route (`/bootstrap` on the storefront origin), while auth had its own fixed API -base. A custom domain could therefore use three different backend origins. - -Directly calling the backend port is not a safe fallback: production returned -`403` for both a bootstrap request carrying `Origin: https://gorbushka.market` -and the corresponding CORS preflight. Meanwhile an unmatched `/bootstrap` on -the storefront nginx server fell through to `index.html`, producing a misleading -HTTP 200 with HTML instead of bootstrap JSON. - -## Decision - -Every tenant frontend uses one API base derived at runtime from the browser -origin: `{origin}/backend`. - -- Bootstrap loads from `{origin}/backend/bootstrap`. -- Auth receives the same base through the `AUTH_API_URL` provider. -- Legacy endpoints append their existing paths to the same base. -- Versioned `/api/...` endpoints retain the `/api` prefix when routed. -- nginx owns `/backend/`, removes that prefix when proxying, and forwards the - original `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto` headers upstream. - -The frontend contains no tenant/domain allowlist and no production backend -hostname. Tenant selection remains a server-side responsibility based on the -verified forwarded host. - -## Consequences - -Browser traffic is same-origin, so custom domains do not require per-tenant CORS -configuration and one bundle works for every attached domain. Bootstrap, auth, -legacy routes, and versioned routes cannot silently drift to different hosts. - -Every nginx tenant/catch-all configuration must include the `/backend/` gateway. -Deploy verification must check that `/backend/bootstrap` returns JSON rather -than accepting a generic HTTP 200 from the SPA fallback. The upstream must still -reject unknown hosts; the gateway preserves `Host` but does not authenticate a -tenant by itself. diff --git a/docs/context/features/platform-vision/FACTS.jsonl b/docs/context/features/platform-vision/FACTS.jsonl index 8a11ea7..0f3a1f9 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":"one same-origin {origin}/backend gateway for bootstrap, auth, legacy endpoints, and versioned /api endpoints; nginx strips /backend and preserves the original Host for server-side tenant resolution","src":["docs/context/adrs/ADR-0004-route-every-tenant-api-through-a-same-origin-gateway.md","src/app/core/config/api-config.service.ts","scripts/deploy/server-setup.sh"],"status":"active","kind":"decision","updated_at":"2026-08-20T09:55:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","nginx"]} +{"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"]} diff --git a/scripts/deploy/add-domain.sh b/scripts/deploy/add-domain.sh index 606e18f..839be8e 100755 --- a/scripts/deploy/add-domain.sh +++ b/scripts/deploy/add-domain.sh @@ -87,16 +87,6 @@ server { proxy_read_timeout 60s; } - location /backend/ { - proxy_pass http://127.0.0.1:8080/; - proxy_http_version 1.1; - proxy_set_header Host \$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 \$scheme; - proxy_read_timeout 60s; - } - location / { try_files \$uri \$uri/ /index.html; } diff --git a/scripts/deploy/server-setup.sh b/scripts/deploy/server-setup.sh index 10a3606..b434655 100755 --- a/scripts/deploy/server-setup.sh +++ b/scripts/deploy/server-setup.sh @@ -104,18 +104,6 @@ server { proxy_read_timeout 60s; } - # One same-origin gateway for bootstrap, auth, legacy and versioned APIs. - # The trailing slash removes /backend/ before forwarding upstream. - location /backend/ { - proxy_pass http://127.0.0.1:8080/; - proxy_http_version 1.1; - proxy_set_header Host $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 $scheme; - proxy_read_timeout 60s; - } - # SPA fallback. Must stay last: every unmatched path is a client route. location / { try_files $uri $uri/ /index.html; diff --git a/scripts/deploy/setup-wildcard-tls.sh b/scripts/deploy/setup-wildcard-tls.sh index be1e01a..08cab62 100644 --- a/scripts/deploy/setup-wildcard-tls.sh +++ b/scripts/deploy/setup-wildcard-tls.sh @@ -119,14 +119,6 @@ server { proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; } - location /backend/ { - proxy_pass http://127.0.0.1:8080/; - proxy_http_version 1.1; - proxy_set_header Host \$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 \$scheme; - } location / { try_files \$uri \$uri/ /index.html; } add_header Strict-Transport-Security "max-age=31536000" always; diff --git a/scripts/deploy/sync-domains.sh b/scripts/deploy/sync-domains.sh index ed6dfba..215f68a 100644 --- a/scripts/deploy/sync-domains.sh +++ b/scripts/deploy/sync-domains.sh @@ -150,14 +150,6 @@ server { proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; } - location /backend/ { - proxy_pass http://127.0.0.1:8080/; - proxy_http_version 1.1; - proxy_set_header Host \$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 \$scheme; - } location / { try_files \$uri \$uri/ /index.html; } add_header X-Content-Type-Options "nosniff" always; diff --git a/src/app/core/bootstrap/providers/api-bootstrap.provider.spec.ts b/src/app/core/bootstrap/providers/api-bootstrap.provider.spec.ts index bef3a4a..851d410 100644 --- a/src/app/core/bootstrap/providers/api-bootstrap.provider.spec.ts +++ b/src/app/core/bootstrap/providers/api-bootstrap.provider.spec.ts @@ -16,7 +16,7 @@ describe('ApiBootstrapProvider', () => { provideHttpClientTesting(), { provide: ApiConfigService, - useValue: { getBaseUrl: () => 'https://gorbushka.market/backend' } + useValue: { getBaseUrl: () => 'https://api.gorbushka.market' } } ] }); @@ -30,7 +30,7 @@ describe('ApiBootstrapProvider', () => { it('loads bootstrap from the same tenant API base as every other request', () => { provider.loadBootstrap().subscribe(); - const request = httpTesting.expectOne('https://gorbushka.market/backend/bootstrap'); + const request = httpTesting.expectOne('https://api.gorbushka.market/bootstrap'); expect(request.request.method).toBe('GET'); request.flush({}); }); diff --git a/src/app/core/config/api-config.service.spec.ts b/src/app/core/config/api-config.service.spec.ts index 4a1c18b..272a4bd 100644 --- a/src/app/core/config/api-config.service.spec.ts +++ b/src/app/core/config/api-config.service.spec.ts @@ -9,10 +9,10 @@ describe('ApiConfigService', () => { beforeEach(() => { tenantResolver = jasmine.createSpyObj( 'TenantResolverService', - ['getHostname', 'getOrigin', 'getTenantKey', 'isLocalhost'] + ['getHostname', 'getProtocol', 'getTenantKey', 'isLocalhost'] ); tenantResolver.getTenantKey.and.returnValue('gorbushka'); - tenantResolver.getOrigin.and.returnValue('https://gorbushka.market'); + tenantResolver.getProtocol.and.returnValue('https:'); tenantResolver.isLocalhost.and.returnValue(false); TestBed.configureTestingModule({ @@ -28,26 +28,30 @@ describe('ApiConfigService', () => { it('uses the current customer hostname for the production API base URL', () => { tenantResolver.getHostname.and.returnValue('gorbushka.market'); - expect(service.getBaseUrl()).toBe('https://gorbushka.market/backend'); + expect(service.getBaseUrl()).toBe('https://api.gorbushka.market'); }); - it('keeps www traffic on the same browser origin', () => { - tenantResolver.getHostname.and.returnValue('www.gorbushka.market'); - tenantResolver.getOrigin.and.returnValue('https://www.gorbushka.market'); + it('preserves every storefront subdomain in the API hostname', () => { + tenantResolver.getHostname.and.returnValue('store1.example.com'); - expect(service.getBaseUrl()).toBe('https://www.gorbushka.market/backend'); + expect(service.getBaseUrl()).toBe('https://api.store1.example.com'); + }); + + it('preserves www like any other storefront subdomain', () => { + tenantResolver.getHostname.and.returnValue('www.gorbushka.market'); + + expect(service.getBaseUrl()).toBe('https://api.www.gorbushka.market'); }); it('preserves the API namespace when targeting a tenant backend', () => { tenantResolver.getHostname.and.returnValue('gorbushka.market'); expect(service.toApiUrl('/api/v2/storefront/cart')) - .toBe('https://gorbushka.market/backend/api/v2/storefront/cart'); + .toBe('https://api.gorbushka.market/api/v2/storefront/cart'); }); it('does not duplicate the API prefix for localhost proxy requests', () => { tenantResolver.getHostname.and.returnValue('localhost'); - tenantResolver.getOrigin.and.returnValue('http://localhost:4200'); tenantResolver.getTenantKey.and.returnValue('default'); tenantResolver.isLocalhost.and.returnValue(true); diff --git a/src/app/core/config/api-config.service.ts b/src/app/core/config/api-config.service.ts index 00dd4b9..f6340c8 100644 --- a/src/app/core/config/api-config.service.ts +++ b/src/app/core/config/api-config.service.ts @@ -8,8 +8,7 @@ export class ApiConfigService { getBaseUrl(): string { const hostname = this.tenantResolver.getHostname(); - const apiHostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname; - const origin = this.tenantResolver.getOrigin(); + const protocol = this.tenantResolver.getProtocol(); const tenantKey = this.tenantResolver.getTenantKey(); const tenantMap = (environment as any).tenantApiBaseUrls as Record | undefined; const localhostUrl = (environment as any).localhostApiUrl as string | undefined; @@ -19,14 +18,14 @@ export class ApiConfigService { if (this.tenantResolver.isLocalhost() && localhostUrl) { url = localhostUrl; - } else if (tenantMap?.[hostname] || tenantMap?.[apiHostname]) { - url = tenantMap[hostname] ?? tenantMap[apiHostname]; + } else if (tenantMap?.[hostname]) { + url = tenantMap[hostname]; } else if (tenantMap?.[tenantKey]) { url = tenantMap[tenantKey]; - } else if (apiTemplate && origin) { + } else if (apiTemplate && hostname) { url = apiTemplate - .replace('{origin}', origin) - .replace('{hostname}', apiHostname) + .replace('{protocol}', protocol) + .replace('{hostname}', hostname) .replace('{tenant}', tenantKey); } diff --git a/src/app/core/config/tenant-resolver.service.ts b/src/app/core/config/tenant-resolver.service.ts index 9b5fc69..8282363 100644 --- a/src/app/core/config/tenant-resolver.service.ts +++ b/src/app/core/config/tenant-resolver.service.ts @@ -11,8 +11,8 @@ export class TenantResolverService { return host.toLowerCase(); } - getOrigin(): string { - return this.document?.location?.origin ?? ''; + getProtocol(): string { + return this.document?.location?.protocol ?? 'https:'; } isLocalhost(): boolean { diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 792ce99..de91bab 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: '{origin}/backend', + tenantApiTemplate: '{protocol}//api.{hostname}', tenantApiBaseUrls: {}, brandName: 'Marketplace', brandFullName: 'Marketplace', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index d5642b1..62a7188 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: '{origin}/backend', + tenantApiTemplate: '{protocol}//api.{hostname}', tenantApiBaseUrls: {}, brandName: 'Marketplace', brandFullName: 'Marketplace',