fix(api): route tenants through origin gateway
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
2026-08-20 14:23:12 +04:00
parent bbf12cad33
commit f4ea4c7af8
18 changed files with 241 additions and 69 deletions

View File

@@ -12,6 +12,7 @@ import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519Ver
import { provideServiceWorker } from '@angular/service-worker';
import { MediaRepository } from './core/media/media-repository';
import { MockMediaRepository } from './core/media/mock-media-repository.service';
import { ApiConfigService } from './core/config/api-config.service';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
@@ -27,7 +28,11 @@ export const appConfig: ApplicationConfig = {
// other interceptor has run, and normalizes whatever actually came back.
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 },
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
// Angular Package Format, so it carries no baked-in Ivy DI metadata for

View File

@@ -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({});
});
});

View File

@@ -3,14 +3,16 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { BootstrapConfig } from '../../../shared/models/config';
import { ConfigProvider } from '../../config/config-provider.interface';
import { ApiConfigService } from '../../config/api-config.service';
@Injectable({ providedIn: 'root' })
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> {
return this.http.get<BootstrapConfig>(this.bootstrapUrl);
return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
}
}

View 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');
});
});

View File

@@ -1,16 +1,16 @@
import { Injectable, inject } from '@angular/core';
import { environment } from '../../../environments/environment';
import { ConfigService } from './config.service';
import { TenantResolverService } from './tenant-resolver.service';
@Injectable({ providedIn: 'root' })
export class ApiConfigService {
private readonly tenantResolver = inject(TenantResolverService);
private readonly configService = inject(ConfigService);
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 bootstrapUrl = this.resolveBootstrapApiBaseUrl();
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
@@ -19,13 +19,15 @@ export class ApiConfigService {
if (this.tenantResolver.isLocalhost() && localhostUrl) {
url = localhostUrl;
} else if (tenantMap?.[hostname] || tenantMap?.[apiHostname]) {
url = tenantMap[hostname] ?? tenantMap[apiHostname];
} else if (tenantMap?.[tenantKey]) {
url = tenantMap[tenantKey];
} else if (apiTemplate) {
url = apiTemplate.replace('{tenant}', tenantKey);
} else if (bootstrapUrl) {
// Bootstrap API override is opt-in and only for absolute URLs.
url = bootstrapUrl;
} else if (apiTemplate && origin) {
url = apiTemplate
.replace('{origin}', origin)
.replace('{hostname}', apiHostname)
.replace('{tenant}', tenantKey);
}
return this.normalizeBaseUrl(url);
@@ -54,36 +56,15 @@ export class ApiConfigService {
}
const baseUrl = this.getBaseUrl();
const path = url.slice('/api'.length);
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
private resolveBootstrapApiBaseUrl(): string | null {
const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
if (!allowBootstrapApiOverride) {
return null;
if (baseUrl === '/') {
return url;
}
const bootstrap = this.configService.getBootstrapSnapshot() as any;
if (!bootstrap) {
return null;
if (baseUrl === '/api' || baseUrl.endsWith('/api')) {
return `${baseUrl}${url.slice('/api'.length)}`;
}
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());
return `${baseUrl}${url}`;
}
private normalizeBaseUrl(url: string): string {
@@ -93,4 +74,4 @@ export class ApiConfigService {
return url.replace(/\/+$/, '');
}
}
}

View File

@@ -11,6 +11,10 @@ export class TenantResolverService {
return host.toLowerCase();
}
getOrigin(): string {
return this.document?.location?.origin ?? '';
}
isLocalhost(): boolean {
const hostname = this.getHostname();
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
@@ -33,4 +37,4 @@ export class TenantResolverService {
return segments[0];
}
}
}