fix(api): route tenants through origin gateway
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
# Deployment — server provisioning, CD, TLS
|
# Deployment — server provisioning, CD, TLS
|
||||||
|
|
||||||
Frontend only. The backend service (`:8080`) is a separate developer's responsibility; nginx already proxies `/api/` to it and will `502` until it exists.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Files
|
## 1. Files
|
||||||
|
|||||||
@@ -10,10 +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:
|
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) derives a `tenantKey` from `window.location.hostname` (first label; `www.` skipped; localhost falls back to a configured key).
|
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) turns that key into the API base URL — via an explicit per-tenant map or a `{tenant}` URL template.
|
2. [`ApiConfigService`](../../src/app/core/config/api-config.service.ts) resolves one same-origin API base: `{origin}/backend`.
|
||||||
3. `ApiBootstrapProvider` fetches that tenant's **bootstrap config**, which drives branding, theme, locales, currencies, navigation, footer, and which pages exist.
|
3. `ApiBootstrapProvider`, auth, legacy API calls, and versioned `/api/...` calls all use that same base.
|
||||||
4. nginx is `default_server` / `server_name _`, so any domain pointed at the server IP gets the same bundle and self-resolves.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -11,3 +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":"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"]}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@
|
|||||||
{
|
{
|
||||||
"name": "api-cache",
|
"name": "api-cache",
|
||||||
"urls": [
|
"urls": [
|
||||||
"/api/**",
|
"/api/**"
|
||||||
"https://api.dexarmarket.ru:445/**",
|
|
||||||
"https://api.novo.market:444/**"
|
|
||||||
],
|
],
|
||||||
"cacheConfig": {
|
"cacheConfig": {
|
||||||
"maxSize": 100,
|
"maxSize": 100,
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
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 / {
|
location / {
|
||||||
try_files \$uri \$uri/ /index.html;
|
try_files \$uri \$uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
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.
|
# SPA fallback. Must stay last: every unmatched path is a client route.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ server {
|
|||||||
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 \$scheme;
|
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; }
|
location / { try_files \$uri \$uri/ /index.html; }
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ server {
|
|||||||
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 \$scheme;
|
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; }
|
location / { try_files \$uri \$uri/ /index.html; }
|
||||||
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519Ver
|
|||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
import { MediaRepository } from './core/media/media-repository';
|
import { MediaRepository } from './core/media/media-repository';
|
||||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||||
|
import { ApiConfigService } from './core/config/api-config.service';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
@@ -27,7 +28,11 @@ export const appConfig: ApplicationConfig = {
|
|||||||
// other interceptor has run, and normalizes whatever actually came back.
|
// other interceptor has run, and normalizes whatever actually came back.
|
||||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
|
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
|
||||||
),
|
),
|
||||||
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
|
{
|
||||||
|
provide: AUTH_API_URL,
|
||||||
|
useFactory: (apiConfig: ApiConfigService) => apiConfig.getBaseUrl(),
|
||||||
|
deps: [ApiConfigService]
|
||||||
|
},
|
||||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||||
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
|
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
|
||||||
// Angular Package Format, so it carries no baked-in Ivy DI metadata for
|
// Angular Package Format, so it carries no baked-in Ivy DI metadata for
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ApiConfigService } from '../../config/api-config.service';
|
||||||
|
import { ApiBootstrapProvider } from './api-bootstrap.provider';
|
||||||
|
|
||||||
|
describe('ApiBootstrapProvider', () => {
|
||||||
|
let provider: ApiBootstrapProvider;
|
||||||
|
let httpTesting: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
ApiBootstrapProvider,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ApiConfigService,
|
||||||
|
useValue: { getBaseUrl: () => 'https://gorbushka.market/backend' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
provider = TestBed.inject(ApiBootstrapProvider);
|
||||||
|
httpTesting = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpTesting.verify());
|
||||||
|
|
||||||
|
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');
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,14 +3,16 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
import { ConfigProvider } from '../../config/config-provider.interface';
|
import { ConfigProvider } from '../../config/config-provider.interface';
|
||||||
|
import { ApiConfigService } from '../../config/api-config.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiBootstrapProvider implements ConfigProvider {
|
export class ApiBootstrapProvider implements ConfigProvider {
|
||||||
private readonly bootstrapUrl = '/bootstrap';
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
constructor(private readonly http: HttpClient) {}
|
private readonly apiConfig: ApiConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
loadBootstrap(): Observable<BootstrapConfig> {
|
loadBootstrap(): Observable<BootstrapConfig> {
|
||||||
return this.http.get<BootstrapConfig>(this.bootstrapUrl);
|
return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/app/core/config/api-config.service.spec.ts
Normal file
64
src/app/core/config/api-config.service.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ApiConfigService } from './api-config.service';
|
||||||
|
import { TenantResolverService } from './tenant-resolver.service';
|
||||||
|
|
||||||
|
describe('ApiConfigService', () => {
|
||||||
|
let service: ApiConfigService;
|
||||||
|
let tenantResolver: jasmine.SpyObj<TenantResolverService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tenantResolver = jasmine.createSpyObj<TenantResolverService>(
|
||||||
|
'TenantResolverService',
|
||||||
|
['getHostname', 'getOrigin', 'getTenantKey', 'isLocalhost']
|
||||||
|
);
|
||||||
|
tenantResolver.getTenantKey.and.returnValue('gorbushka');
|
||||||
|
tenantResolver.getOrigin.and.returnValue('https://gorbushka.market');
|
||||||
|
tenantResolver.isLocalhost.and.returnValue(false);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
ApiConfigService,
|
||||||
|
{ provide: TenantResolverService, useValue: tenantResolver }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps www traffic on the same browser origin', () => {
|
||||||
|
tenantResolver.getHostname.and.returnValue('www.gorbushka.market');
|
||||||
|
tenantResolver.getOrigin.and.returnValue('https://www.gorbushka.market');
|
||||||
|
|
||||||
|
expect(service.getBaseUrl()).toBe('https://www.gorbushka.market/backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(service.toApiUrl('/api/v2/storefront/cart')).toBe('/api/v2/storefront/cart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves absolute and non-API URLs unchanged', () => {
|
||||||
|
tenantResolver.getHostname.and.returnValue('gorbushka.market');
|
||||||
|
|
||||||
|
expect(service.toApiUrl('https://cdn.example.com/image.png'))
|
||||||
|
.toBe('https://cdn.example.com/image.png');
|
||||||
|
expect(service.toApiUrl('/assets/config.json')).toBe('/assets/config.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ConfigService } from './config.service';
|
|
||||||
import { TenantResolverService } from './tenant-resolver.service';
|
import { TenantResolverService } from './tenant-resolver.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiConfigService {
|
export class ApiConfigService {
|
||||||
private readonly tenantResolver = inject(TenantResolverService);
|
private readonly tenantResolver = inject(TenantResolverService);
|
||||||
private readonly configService = inject(ConfigService);
|
|
||||||
|
|
||||||
getBaseUrl(): string {
|
getBaseUrl(): string {
|
||||||
|
const hostname = this.tenantResolver.getHostname();
|
||||||
|
const apiHostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
|
||||||
|
const origin = this.tenantResolver.getOrigin();
|
||||||
const tenantKey = this.tenantResolver.getTenantKey();
|
const tenantKey = this.tenantResolver.getTenantKey();
|
||||||
const bootstrapUrl = this.resolveBootstrapApiBaseUrl();
|
|
||||||
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
|
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
|
||||||
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
|
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
|
||||||
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
|
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
|
||||||
@@ -19,13 +19,15 @@ export class ApiConfigService {
|
|||||||
|
|
||||||
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
if (this.tenantResolver.isLocalhost() && localhostUrl) {
|
||||||
url = localhostUrl;
|
url = localhostUrl;
|
||||||
|
} else if (tenantMap?.[hostname] || tenantMap?.[apiHostname]) {
|
||||||
|
url = tenantMap[hostname] ?? tenantMap[apiHostname];
|
||||||
} else if (tenantMap?.[tenantKey]) {
|
} else if (tenantMap?.[tenantKey]) {
|
||||||
url = tenantMap[tenantKey];
|
url = tenantMap[tenantKey];
|
||||||
} else if (apiTemplate) {
|
} else if (apiTemplate && origin) {
|
||||||
url = apiTemplate.replace('{tenant}', tenantKey);
|
url = apiTemplate
|
||||||
} else if (bootstrapUrl) {
|
.replace('{origin}', origin)
|
||||||
// Bootstrap API override is opt-in and only for absolute URLs.
|
.replace('{hostname}', apiHostname)
|
||||||
url = bootstrapUrl;
|
.replace('{tenant}', tenantKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.normalizeBaseUrl(url);
|
return this.normalizeBaseUrl(url);
|
||||||
@@ -54,36 +56,15 @@ export class ApiConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.getBaseUrl();
|
const baseUrl = this.getBaseUrl();
|
||||||
const path = url.slice('/api'.length);
|
if (baseUrl === '/') {
|
||||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveBootstrapApiBaseUrl(): string | null {
|
if (baseUrl === '/api' || baseUrl.endsWith('/api')) {
|
||||||
const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
|
return `${baseUrl}${url.slice('/api'.length)}`;
|
||||||
if (!allowBootstrapApiOverride) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrap = this.configService.getBootstrapSnapshot() as any;
|
return `${baseUrl}${url}`;
|
||||||
if (!bootstrap) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl;
|
|
||||||
if (typeof endpointBase === 'string' && this.isAbsoluteHttpUrl(endpointBase)) {
|
|
||||||
return endpointBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
|
|
||||||
if (typeof tenantBase === 'string' && this.isAbsoluteHttpUrl(tenantBase)) {
|
|
||||||
return tenantBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAbsoluteHttpUrl(url: string): boolean {
|
|
||||||
return /^https?:\/\//i.test(url.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeBaseUrl(url: string): string {
|
private normalizeBaseUrl(url: string): string {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export class TenantResolverService {
|
|||||||
return host.toLowerCase();
|
return host.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrigin(): string {
|
||||||
|
return this.document?.location?.origin ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
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';
|
||||||
|
|||||||
@@ -3,18 +3,13 @@ export const environment = {
|
|||||||
production: true,
|
production: true,
|
||||||
useMockBootstrapOnLocal: false,
|
useMockBootstrapOnLocal: false,
|
||||||
fallbackTenantKey: 'default',
|
fallbackTenantKey: 'default',
|
||||||
allowBootstrapApiOverride: false,
|
localhostApiUrl: '/api',
|
||||||
localhostApiUrl: 'https://api.dexarmarket.ru:445',
|
tenantApiTemplate: '{origin}/backend',
|
||||||
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445',
|
tenantApiBaseUrls: {},
|
||||||
tenantApiBaseUrls: {
|
|
||||||
default: 'https://api.dexarmarket.ru:445',
|
|
||||||
dexarmarket: 'https://api.dexarmarket.ru:445'
|
|
||||||
},
|
|
||||||
brandName: 'Marketplace',
|
brandName: 'Marketplace',
|
||||||
brandFullName: 'Marketplace',
|
brandFullName: 'Marketplace',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
apiUrl: '/api',
|
||||||
authApiUrl: 'https://api.dexarmarket.ru:445',
|
|
||||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||||
logo: '/icons/icon-192x192.png',
|
logo: '/icons/icon-192x192.png',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
|
|||||||
@@ -4,18 +4,13 @@ export const environment = {
|
|||||||
useMockData: false, // Toggle to test with backOffice mock data
|
useMockData: false, // Toggle to test with backOffice mock data
|
||||||
useMockBootstrapOnLocal: true,
|
useMockBootstrapOnLocal: true,
|
||||||
fallbackTenantKey: 'default',
|
fallbackTenantKey: 'default',
|
||||||
allowBootstrapApiOverride: false,
|
|
||||||
localhostApiUrl: '/api',
|
localhostApiUrl: '/api',
|
||||||
tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445',
|
tenantApiTemplate: '{origin}/backend',
|
||||||
tenantApiBaseUrls: {
|
tenantApiBaseUrls: {},
|
||||||
default: 'https://api.dexarmarket.ru:445',
|
|
||||||
dexarmarket: 'https://api.dexarmarket.ru:445'
|
|
||||||
},
|
|
||||||
brandName: 'Marketplace',
|
brandName: 'Marketplace',
|
||||||
brandFullName: 'Marketplace',
|
brandFullName: 'Marketplace',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
authApiUrl: 'https://api.dexarmarket.ru:445',
|
|
||||||
qrApiUrl: 'https://qr.vitanova.network/api',
|
qrApiUrl: 'https://qr.vitanova.network/api',
|
||||||
logo: '/icons/icon-192x192.png',
|
logo: '/icons/icon-192x192.png',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
|
|||||||
@@ -43,10 +43,6 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
<meta name="apple-mobile-web-app-title" content="Marketplace">
|
<meta name="apple-mobile-web-app-title" content="Marketplace">
|
||||||
|
|
||||||
<!-- Preconnect to API -->
|
|
||||||
<link rel="preconnect" href="https://api.dexarmarket.ru" crossorigin="">
|
|
||||||
<link rel="dns-prefetch" href="https://api.dexarmarket.ru">
|
|
||||||
|
|
||||||
<!-- Preload critical assets for better LCP -->
|
<!-- Preload critical assets for better LCP -->
|
||||||
<link rel="preload" href="/icons/icon-192x192.png" as="image" type="image/png">
|
<link rel="preload" href="/icons/icon-192x192.png" as="image" type="image/png">
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user