diff --git a/packages/core/jest.setup.js b/packages/core/jest.setup.js index 62647ef99..27ba411c8 100644 --- a/packages/core/jest.setup.js +++ b/packages/core/jest.setup.js @@ -5,7 +5,7 @@ import { createMockUtils } from '@logto/shared/esm'; const { jest } = import.meta; -const { mockEsm, mockEsmWithActual, mockEsmDefault } = createMockUtils(jest); +const { mockEsm, mockEsmDefault } = createMockUtils(jest); process.env.DB_URL = 'postgres://mock.db.url'; process.env.ENDPOINT = 'https://logto.test'; @@ -29,16 +29,6 @@ mockEsmDefault('#src/env-set/oidc.js', () => () => ({ })); /* End */ -await mockEsmWithActual('#src/env-set/index.js', () => ({ - MountedApps: { - Api: 'api', - Oidc: 'oidc', - Console: 'console', - DemoApp: 'demo-app', - Welcome: 'welcome', - }, -})); - // Logger is not considered in all test cases // eslint-disable-next-line unicorn/consistent-function-scoping mockEsm('koa-logger', () => ({ default: () => (_, next) => next() })); diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 99005dbf8..27b47c7d4 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -13,7 +13,7 @@ const logListening = (type: 'core' | 'admin' = 'core') => { const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet; for (const url of urlSet.deduplicated()) { - console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url}`))); + console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url.toString()}`))); } }; diff --git a/packages/core/src/env-set/GlobalValues.ts b/packages/core/src/env-set/GlobalValues.ts index a3aa0b9a3..06ef9439a 100644 --- a/packages/core/src/env-set/GlobalValues.ts +++ b/packages/core/src/env-set/GlobalValues.ts @@ -5,10 +5,6 @@ import UrlSet from './UrlSet.js'; import { isTrue } from './parameters.js'; import { throwErrorWithDsnMessage } from './throw-errors.js'; -const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID'; - -type MultiTenancyMode = 'domain' | 'env'; - export default class GlobalValues { public readonly isProduction = getEnv('NODE_ENV') === 'production'; public readonly isTest = getEnv('NODE_ENV') === 'test'; @@ -21,11 +17,11 @@ export default class GlobalValues { public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001); public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_'); - public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.includes('*'); + public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*'); // eslint-disable-next-line unicorn/consistent-function-scoping public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage); - public readonly developmentTenantId = getEnv(developmentTenantIdKey); + public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID'); public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'); public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID'); public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER')); @@ -35,7 +31,7 @@ export default class GlobalValues { return this.databaseUrl; } - public get endpoint(): string { + public get endpoint(): URL { return this.urlSet.endpoint; } } diff --git a/packages/core/src/env-set/UrlSet.test.ts b/packages/core/src/env-set/UrlSet.test.ts new file mode 100644 index 000000000..37a2536b6 --- /dev/null +++ b/packages/core/src/env-set/UrlSet.test.ts @@ -0,0 +1,81 @@ +import UrlSet from './UrlSet.js'; + +describe('UrlSet', () => { + const backupEnv = process.env; + + afterEach(() => { + process.env = backupEnv; + }); + + it('should resolve proper values when localhost is enabled and endpoint is provided', async () => { + process.env = { + ...backupEnv, + ENDPOINT: 'https://logto.mock', + ADMIN_ENDPOINT: 'https://admin.logto.mock', + }; + + const set1 = new UrlSet(true, 3001); + + expect(set1.deduplicated()).toStrictEqual([ + new URL('https://localhost:3001'), + new URL('https://logto.mock'), + ]); + expect(set1.port).toEqual(3001); + expect(set1.localhostUrl).toEqual(new URL('https://localhost:3001')); + expect(set1.endpoint).toEqual(new URL('https://logto.mock')); + + const set2 = new UrlSet(false, 3002, 'ADMIN_'); + + expect(set2.deduplicated()).toStrictEqual([ + new URL('http://localhost:3002/'), + new URL('https://admin.logto.mock/'), + ]); + expect(set2.port).toEqual(3002); + expect(set2.localhostUrl).toEqual(new URL('http://localhost:3002')); + expect(set2.endpoint).toEqual(new URL('https://admin.logto.mock')); + }); + + it('should resolve proper values when localhost is enabled and endpoint is not provided', async () => { + process.env = { + ...backupEnv, + ENDPOINT: undefined, + }; + + const set1 = new UrlSet(false, 3001); + + expect(set1.deduplicated()).toStrictEqual([new URL('http://localhost:3001/')]); + expect(set1.port).toEqual(3001); + expect(set1.localhostUrl).toEqual(new URL('http://localhost:3001')); + expect(set1.endpoint).toEqual(new URL('http://localhost:3001')); + }); + + it('should resolve proper values when localhost is disabled and endpoint is provided', async () => { + process.env = { + ...backupEnv, + ENDPOINT: 'https://logto.mock/logto', + DISABLE_LOCALHOST: '1', + }; + + const set1 = new UrlSet(true, 3001); + + expect(set1.deduplicated()).toStrictEqual([new URL('https://logto.mock/logto')]); + expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.'); + expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.'); + expect(set1.endpoint).toEqual(new URL('https://logto.mock/logto')); + }); + + it('should resolve proper values when localhost is disabled and endpoint is not provided', async () => { + process.env = { + ...backupEnv, + ADMIN_ENDPOINT: undefined, + ADMIN_DISABLE_LOCALHOST: '1', + }; + + const set1 = new UrlSet(false, 3002, 'ADMIN_'); + + expect(set1.deduplicated()).toStrictEqual([]); + expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.'); + expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.'); + expect(() => set1.endpoint).toThrowError('No available endpoint in this URL Set.'); + }); +}); diff --git a/packages/core/src/env-set/UrlSet.ts b/packages/core/src/env-set/UrlSet.ts index cccd37fa2..7c01c7d02 100644 --- a/packages/core/src/env-set/UrlSet.ts +++ b/packages/core/src/env-set/UrlSet.ts @@ -2,8 +2,6 @@ import { deduplicate, getEnv, trySafe } from '@silverhand/essentials'; import { isTrue } from './parameters.js'; -const localhostDisabledMessage = 'Localhost has been disabled in this URL Set.'; - export default class UrlSet { readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort); readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT'); @@ -16,27 +14,35 @@ export default class UrlSet { protected readonly envPrefix: string = '' ) {} - public deduplicated(): string[] { + public deduplicated(): URL[] { return deduplicate( - [trySafe(() => this.localhostUrl), trySafe(() => this.endpoint)].filter( + [trySafe(() => this.localhostUrl.toString()), trySafe(() => this.endpoint.toString())].filter( (value): value is string => typeof value === 'string' ) - ); + ).map((value) => new URL(value)); } - public get port() { + public get port(): number { if (this.isLocalhostDisabled) { - throw new Error(localhostDisabledMessage); + throw new Error('Localhost has been disabled in this URL Set.'); } return this.#port; } - public get localhostUrl() { - return `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`; + public get localhostUrl(): URL { + return new URL(`${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`); } - public get endpoint() { - return this.#endpoint || this.localhostUrl; + public get endpoint(): URL { + if (!this.#endpoint) { + if (this.isLocalhostDisabled) { + throw new Error('No available endpoint in this URL Set.'); + } + + return this.localhostUrl; + } + + return new URL(this.#endpoint); } } diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 01268578f..c8a99451a 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,6 +1,4 @@ -import { adminTenantId } from '@logto/schemas'; import type { Optional } from '@silverhand/essentials'; -import { trySafe } from '@silverhand/essentials'; import type { PostgreSql } from '@withtyped/postgres'; import type { QueryClient } from '@withtyped/server'; import type { DatabasePool } from 'slonik'; @@ -14,6 +12,7 @@ import createPool from './create-pool.js'; import createQueryClient from './create-query-client.js'; import loadOidcValues from './oidc.js'; import { throwNotLoadedError } from './throw-errors.js'; +import { getTenantEndpoint } from './utils.js'; /** Apps (also paths) for user tenants. */ export enum UserApps { @@ -29,21 +28,6 @@ export enum AdminApps { Me = 'me', } -const getTenantEndpoint = (id: string) => { - const { urlSet, adminUrlSet, isDomainBasedMultiTenancy } = EnvSet.values; - const adminUrl = trySafe(() => adminUrlSet.endpoint); - - if (adminUrl && id === adminTenantId) { - return adminUrl; - } - - if (!isDomainBasedMultiTenancy) { - return urlSet.endpoint; - } - - return urlSet.endpoint.replace('*', id); -}; - export class EnvSet { static values = new GlobalValues(); @@ -109,7 +93,9 @@ export class EnvSet { const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool)); const oidcConfigs = await getOidcConfigs(); - const endpoint = getTenantEndpoint(this.tenantId); + const endpoint = getTenantEndpoint(this.tenantId, EnvSet.values); this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs); } } + +export { getTenantEndpoint } from './utils.js'; diff --git a/packages/core/src/env-set/utils.ts b/packages/core/src/env-set/utils.ts new file mode 100644 index 000000000..cb8834b30 --- /dev/null +++ b/packages/core/src/env-set/utils.ts @@ -0,0 +1,25 @@ +import { adminTenantId } from '@logto/schemas'; +import { trySafe } from '@silverhand/essentials'; + +import type GlobalValues from './GlobalValues.js'; + +export const getTenantEndpoint = ( + id: string, + { urlSet, adminUrlSet, isDomainBasedMultiTenancy }: GlobalValues +): URL => { + const adminUrl = trySafe(() => adminUrlSet.endpoint); + + if (adminUrl && id === adminTenantId) { + return adminUrl; + } + + if (!isDomainBasedMultiTenancy) { + return urlSet.endpoint; + } + + const tenantUrl = new URL(urlSet.endpoint); + // eslint-disable-next-line @silverhand/fp/no-mutation + tenantUrl.hostname = tenantUrl.hostname.replace('*', id); + + return tenantUrl; +}; diff --git a/packages/core/src/middleware/koa-auth/utils.ts b/packages/core/src/middleware/koa-auth/utils.ts index 198d5cc57..73bd053c0 100644 --- a/packages/core/src/middleware/koa-auth/utils.ts +++ b/packages/core/src/middleware/koa-auth/utils.ts @@ -11,8 +11,9 @@ import { convertToIdentifiers } from '@logto/shared'; import type { JWK } from 'jose'; import { sql } from 'slonik'; -import { EnvSet } from '#src/env-set/index.js'; +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import { exportJWK } from '#src/utils/jwks.js'; +import { appendPath } from '#src/utils/url.js'; const { table, fields } = convertToIdentifiers(LogtoConfigs); @@ -46,9 +47,12 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{ return { keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))), issuer: [ - (isDomainBasedMultiTenancy - ? urlSet.endpoint.replace('*', adminTenantId) - : adminUrlSet.endpoint) + '/oidc', + appendPath( + isDomainBasedMultiTenancy + ? getTenantEndpoint(adminTenantId, EnvSet.values) + : adminUrlSet.endpoint, + '/oidc' + ).toString(), ], }; }; diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 29157640c..9696a5a1f 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -3,7 +3,8 @@ import { ConnectorType } from '@logto/connector-kit'; import { adminConsoleApplicationId, adminTenantId } from '@logto/schemas'; import etag from 'etag'; -import { EnvSet } from '#src/env-set/index.js'; +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; import { getApplicationIdFromInteraction } from '#src/libraries/session.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; @@ -18,8 +19,12 @@ export default function wellKnownRoutes( if (id === adminTenantId) { router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => { + if (!ctx.params.tenantId) { + throw new RequestError('request.invalid_input'); + } + ctx.body = { - user: EnvSet.values.urlSet.endpoint.replace('*', ctx.params.tenantId ?? '*'), + user: getTenantEndpoint(ctx.params.tenantId, EnvSet.values), }; return next(); diff --git a/packages/core/src/tenants/Tenant.test.ts b/packages/core/src/tenants/Tenant.test.ts index 40f8a303c..d7f259940 100644 --- a/packages/core/src/tenants/Tenant.test.ts +++ b/packages/core/src/tenants/Tenant.test.ts @@ -1,21 +1,13 @@ +import { adminTenantId, defaultTenantId } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { emptyMiddleware } from '#src/utils/test-utils.js'; -import { defaultTenant } from './consts.js'; - const { jest } = import.meta; const { mockEsm, mockEsmDefault } = createMockUtils(jest); -const middlewareList = [ - 'error-handler', - 'i18next', - 'audit-log', - 'oidc-error-handler', - 'slonik-error-handler', - 'spa-proxy', -].map((name) => { +const buildMockMiddleware = (name: string) => { const mock = jest.fn(() => emptyMiddleware); mockEsm(`#src/middleware/koa-${name}.js`, () => ({ default: mock, @@ -23,7 +15,27 @@ const middlewareList = [ })); return mock; -}); +}; + +const middlewareList = Object.freeze( + [ + 'error-handler', + 'i18next', + 'audit-log', + 'oidc-error-handler', + 'slonik-error-handler', + 'spa-proxy', + 'check-demo-app', + 'console-redirect-proxy', + ].map((name) => [name, buildMockMiddleware(name)] as const) +); + +const userMiddlewareList = middlewareList.map( + ([name, middleware]) => [name, middleware, name !== 'console-redirect-proxy'] as const +); +const adminMiddlewareList = middlewareList.map( + ([name, middleware]) => [name, middleware, name !== 'check-demo-app'] as const +); mockEsm('./utils.js', () => ({ getTenantDatabaseDsn: async () => 'postgres://mock.db.url', @@ -35,11 +47,38 @@ mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider()); const Tenant = await pickDefault(import('./Tenant.js')); describe('Tenant', () => { - it('should call middleware factories', async () => { - await Tenant.create(defaultTenant); + afterEach(() => { + jest.clearAllMocks(); + }); - for (const middleware of middlewareList) { - expect(middleware).toBeCalled(); + it('should call middleware factories for user tenants', async () => { + await Tenant.create(defaultTenantId); + + for (const [, middleware, shouldCall] of userMiddlewareList) { + if (shouldCall) { + expect(middleware).toBeCalled(); + } else { + expect(middleware).not.toBeCalled(); + } + } + }); + + it('should call middleware factories for the admin tenant', async () => { + await Tenant.create(adminTenantId); + + for (const [, middleware, shouldCall] of adminMiddlewareList) { + if (shouldCall) { + expect(middleware).toBeCalled(); + } else { + expect(middleware).not.toBeCalled(); + } } }); }); + +describe('Tenant `.run()`', () => { + it('should return a function ', async () => { + const tenant = await Tenant.create(defaultTenantId); + expect(typeof tenant.run).toBe('function'); + }); +}); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index a6627618b..8e03ac3c5 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -92,19 +92,19 @@ export default class Tenant implements TenantContext { koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console) ) ); + } else { + // Mount demo app + app.use( + mount( + '/' + UserApps.DemoApp, + compose([ + koaCheckDemoApp(this.queries), + koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp), + ]) + ) + ); } - // Mount demo app - app.use( - mount( - '/' + UserApps.DemoApp, - compose([ - koaCheckDemoApp(this.queries), - koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp), - ]) - ) - ); - // Mount UI app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)])); diff --git a/packages/core/src/tenants/consts.ts b/packages/core/src/tenants/consts.ts deleted file mode 100644 index c8fad436a..000000000 --- a/packages/core/src/tenants/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const defaultTenant = 'default'; diff --git a/packages/core/src/tenants/index.ts b/packages/core/src/tenants/index.ts index 836235aec..c27691ec7 100644 --- a/packages/core/src/tenants/index.ts +++ b/packages/core/src/tenants/index.ts @@ -32,5 +32,4 @@ export class TenantPool { export const tenantPool = new TenantPool(); -export * from './consts.js'; export * from './utils.js'; diff --git a/packages/core/src/test-utils/env-set.ts b/packages/core/src/test-utils/env-set.ts index 4f0835d24..5f690d9f4 100644 --- a/packages/core/src/test-utils/env-set.ts +++ b/packages/core/src/test-utils/env-set.ts @@ -1,5 +1,7 @@ +import { defaultTenantId } from '@logto/schemas'; + import { EnvSet } from '#src/env-set/index.js'; -export const mockEnvSet = new EnvSet(EnvSet.values.endpoint, EnvSet.values.dbUrl); +export const mockEnvSet = new EnvSet(defaultTenantId, EnvSet.values.dbUrl); await mockEnvSet.load(); diff --git a/packages/core/src/utils/tenant.test.ts b/packages/core/src/utils/tenant.test.ts new file mode 100644 index 000000000..de1cd2b78 --- /dev/null +++ b/packages/core/src/utils/tenant.test.ts @@ -0,0 +1,95 @@ +import { adminTenantId, defaultTenantId } from '@logto/schemas'; +import { createMockUtils } from '@logto/shared/esm'; + +import GlobalValues from '#src/env-set/GlobalValues.js'; + +const { jest } = import.meta; + +const { mockEsmWithActual } = createMockUtils(jest); + +await mockEsmWithActual('#src/env-set/index.js', () => ({ + EnvSet: { + get values() { + return new GlobalValues(); + }, + }, +})); + +const { getTenantId } = await import('./tenant.js'); + +describe('getTenantId()', () => { + const backupEnv = process.env; + + afterEach(() => { + process.env = backupEnv; + }); + + it('should resolve development tenant ID when needed', async () => { + process.env = { + ...backupEnv, + NODE_ENV: 'test', + DEVELOPMENT_TENANT_ID: 'foo', + }; + + expect(getTenantId(new URL('https://some.random.url'))).toBe('foo'); + + process.env = { + ...backupEnv, + NODE_ENV: 'production', + INTEGRATION_TEST: 'true', + DEVELOPMENT_TENANT_ID: 'bar', + }; + + expect(getTenantId(new URL('https://some.random.url'))).toBe('bar'); + }); + + it('should resolve proper tenant ID for similar localhost endpoints', async () => { + expect(getTenantId(new URL('http://localhost:3002/some/path////'))).toBe(adminTenantId); + expect(getTenantId(new URL('http://localhost:30021/some/path'))).toBe(defaultTenantId); + expect(getTenantId(new URL('http://localhostt:30021/some/path'))).toBe(defaultTenantId); + expect(getTenantId(new URL('https://localhost:3002'))).toBe(defaultTenantId); + }); + + it('should resolve proper tenant ID for similar domain endpoints', async () => { + process.env = { + ...backupEnv, + NODE_ENV: 'production', + ENDPOINT: 'https://foo.*.logto.mock/app', + }; + + expect(getTenantId(new URL('https://foo.foo.logto.mock/app///asdasd'))).toBe('foo'); + expect(getTenantId(new URL('https://foo.*.logto.mock/app'))).toBe(undefined); + expect(getTenantId(new URL('https://foo.foo.logto.mockk/app///asdasd'))).toBe(undefined); + expect(getTenantId(new URL('https://foo.foo.logto.mock/appp'))).toBe(undefined); + expect(getTenantId(new URL('https://foo.foo.logto.mock:1/app/'))).toBe(undefined); + expect(getTenantId(new URL('http://foo.foo.logto.mock/app'))).toBe(undefined); + expect(getTenantId(new URL('https://user.foo.bar.logto.mock/app'))).toBe(undefined); + expect(getTenantId(new URL('https://foo.bar.bar.logto.mock/app'))).toBe(undefined); + }); + + it('should resolve proper tenant ID if admin localhost is disabled', async () => { + process.env = { + ...backupEnv, + NODE_ENV: 'production', + PORT: '5000', + ENDPOINT: 'https://user.*.logto.mock/app', + ADMIN_ENDPOINT: 'https://admin.logto.mock/app', + ADMIN_DISABLE_LOCALHOST: '1', + }; + + expect(getTenantId(new URL('http://localhost:5000/app///asdasd'))).toBe(defaultTenantId); + expect(getTenantId(new URL('http://localhost:3002/app///asdasd'))).toBe(undefined); + expect(getTenantId(new URL('https://user.foo.logto.mock/app'))).toBe('foo'); + expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe(undefined); // Admin endpoint is explicitly set + expect(getTenantId(new URL('https://admin.logto.mock/app'))).toBe(adminTenantId); + + process.env = { + ...backupEnv, + NODE_ENV: 'production', + PORT: '5000', + ENDPOINT: 'https://user.*.logto.mock/app', + ADMIN_DISABLE_LOCALHOST: '1', + }; + expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe('admin'); + }); +}); diff --git a/packages/core/src/utils/tenant.ts b/packages/core/src/utils/tenant.ts index de123cd2e..3db1543eb 100644 --- a/packages/core/src/utils/tenant.ts +++ b/packages/core/src/utils/tenant.ts @@ -1,6 +1,18 @@ import { adminTenantId, defaultTenantId } from '@logto/schemas'; +import { conditionalString } from '@silverhand/essentials'; -import { EnvSet } from '#src/env-set/index.js'; +import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; + +const normalizePathname = (pathname: string) => + pathname + conditionalString(!pathname.endsWith('/') && '/'); + +const isEndpointOf = (current: URL, endpoint: URL) => { + // Make sure current pathname fragments start with endpoint's + return ( + current.origin === endpoint.origin && + normalizePathname(current.pathname).startsWith(normalizePathname(endpoint.pathname)) + ); +}; export const getTenantId = (url: URL) => { const { @@ -16,18 +28,25 @@ export const getTenantId = (url: URL) => { return developmentTenantId; } - const urlString = url.toString(); - - if (adminUrlSet.deduplicated().some((value) => urlString.startsWith(value))) { + if (adminUrlSet.deduplicated().some((endpoint) => isEndpointOf(url, endpoint))) { return adminTenantId; } if ( !isDomainBasedMultiTenancy || - (!urlSet.isLocalhostDisabled && urlString.startsWith(urlSet.localhostUrl)) + (!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl)) ) { return defaultTenantId; } - return new RegExp(urlSet.endpoint.replace('*', '([^.]*)')).exec(urlString)?.[1]; + const toMatch = urlSet.endpoint.hostname.replace('*', '([^.]*)'); + const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1]; + + if (!matchedId || matchedId === '*') { + return; + } + + if (isEndpointOf(url, getTenantEndpoint(matchedId, EnvSet.values))) { + return matchedId; + } };