diff --git a/docs/multi-domains.md b/docs/multi-domains.md
index 7208d2148..d97599bc2 100644
--- a/docs/multi-domains.md
+++ b/docs/multi-domains.md
@@ -34,7 +34,6 @@ ILC can handle requests from multiple domains so that you don't need to roll out
1. In the top right corner, click **+ Create special route**.
**In the general tab:**
-
1. In the **Special role** dropdown, select **404**.
1. In the **Domain** dropdown, select your domain.
@@ -172,10 +171,10 @@ When canonical domain is set, ILC generates canonical tags using the canonical d
When both route `canonicalUrl` (in route metadata) and domain `canonicalDomain` are set, they combine:
-- Request: `https://mirror.example.com/products/variant-123`
-- Route metadata: `{ "canonicalUrl": "/products/main" }`
-- Canonical domain: `www.example.com`
-- Result: `https://www.example.com/products/main`
+- Request: `https://mirror.example.com/products/variant-123`
+- Route metadata: `{ "canonicalUrl": "/products/main" }`
+- Canonical domain: `www.example.com`
+- Result: `https://www.example.com/products/main`
See [Route metadata canonicalUrl](routing/route_configuration.md#canonicalurl) for more information.
@@ -183,8 +182,42 @@ See [Route metadata canonicalUrl](routing/route_configuration.md#canonicalurl) f
ILC automatically updates canonical tags during client-side navigation.
+## Domain alias
+
+The **alias** field provides a stable, human-readable identifier for a router domain. Unlike the auto-incremented numeric `id` or the `domainName` (which may differ between environments), the alias is a short slug that stays consistent across multiple ILC instances.
+
+### Why it's needed
+
+In setups with multiple ILC instances (e.g., staging and production, or multiple brands sharing route configuration), routes are often managed programmatically via the Registry API. Using the numeric `id` to reference a domain is fragile because IDs differ between instances. Using `domainName` is also fragile because the actual hostname may differ (e.g., `shop.example.com` in production vs. `shop.staging.example.com`).
+
+An alias like `main-shop` can be identical across all instances, so a route payload referencing `domainAlias: "main-shop"` will bind correctly regardless of the instance it is applied to.
+
+### Configure an alias
+
+1. Open the ILC Registry and navigate to **Router domains**.
+2. Select an existing domain or click **+ Create** to add a new one.
+3. In the **Alias** field, enter a short identifier using only lowercase letters, digits, and hyphens (e.g., `main-shop`). Maximum 64 characters.
+4. Click **Save**.
+
+### Using `domainAlias` in routes
+
+When creating or updating a route via the API, you can supply `domainAlias` instead of `domainId`. The two fields are mutually exclusive — provide exactly one or neither.
+
+```json
+{
+ "route": "/checkout",
+ "domainAlias": "main-shop",
+ "slots": { ... }
+}
+```
+
+The Registry resolves the alias to the corresponding `domainId` at write time. If no router domain with that alias exists, the request is rejected with a validation error.
+
+!!! note ""
+The alias must be unique across all router domains within an ILC instance.
+
## Additional information
-- ILC detects a domain from the [**request.host** of Fastify](https://www.fastify.io/docs/latest/Reference/Request/) and checks whether this hostname is listed in the **Router domains**.
-- Each registered domain in the **Router domains** has its own set of routes that do not overlap.
-- For routes, the domain is optional. If the request goes from the domain that is not listed in the **Router domains**, the routes for the request will stay unassigned.
+- ILC detects a domain from the [**request.host** of Fastify](https://www.fastify.io/docs/latest/Reference/Request/) and checks whether this hostname is listed in the **Router domains**.
+- Each registered domain in the **Router domains** has its own set of routes that do not overlap.
+- For routes, the domain is optional. If the request goes from the domain that is not listed in the **Router domains**, the routes for the request will stay unassigned.
diff --git a/registry/client/README.md b/registry/client/README.md
index 401d89061..db029e4cf 100755
--- a/registry/client/README.md
+++ b/registry/client/README.md
@@ -7,7 +7,7 @@ $ npm install
$ npm start
```
-And then browse to [http://localhost:8080/](http://localhost:8080/).
+And then browse to [http://localhost:4001/](http://localhost:4001/).
The default credentials are:
**root / pwd** - for admin access.
@@ -44,4 +44,15 @@ Or provide it to the docker container itself.
# Parts of UI
-- [Router domains](./docs/multi-domains.md)
+- [Router domains](./docs/multi-domains.md)
+
+## Router domain fields
+
+| Field | Description |
+| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `domainName` | Hostname of the domain (e.g. `example.com`). Used by ILC at runtime to match incoming requests. |
+| `template500` | Default 500 error template for this domain. |
+| `canonicalDomain` | Alternative domain used for canonical `` tags. |
+| `brandId` | Brand identifier for multi-brand setups. |
+| `alias` | Stable human-readable slug (e.g. `main-shop`). Allows routes to reference this domain by alias instead of numeric ID, which is useful when synchronizing route configuration across multiple ILC instances where IDs may differ. |
+| `props` / `ssrProps` | Domain-level properties merged into all applications running on this domain. |
diff --git a/registry/client/src/routerDomains/Edit.js b/registry/client/src/routerDomains/Edit.js
index 44fe073a4..548f51fa0 100644
--- a/registry/client/src/routerDomains/Edit.js
+++ b/registry/client/src/routerDomains/Edit.js
@@ -29,6 +29,7 @@ const InputForm = ({ mode = 'edit', ...props }) => {
+
diff --git a/registry/client/src/routerDomains/List.js b/registry/client/src/routerDomains/List.js
index b5eae8911..a5ac6ac5b 100644
--- a/registry/client/src/routerDomains/List.js
+++ b/registry/client/src/routerDomains/List.js
@@ -33,6 +33,7 @@ const PostList = (props) => {
+
diff --git a/registry/lde/oauth-server.ts b/registry/lde/oauth-server.ts
index 9f1add93d..3b5f66cd5 100644
--- a/registry/lde/oauth-server.ts
+++ b/registry/lde/oauth-server.ts
@@ -7,8 +7,8 @@ import { OAuth2Server } from 'oauth2-mock-server';
await server.issuer.keys.generate('RS256');
// Start the server
- await server.start(8080);
- console.log('Issuer URL:', server.issuer.url); // -> http://localhost:8080
+ await server.start(8085);
+ console.log('Issuer URL:', server.issuer.url);
server.service.on('beforeTokenSigning', (token, req) => {
token.payload.unique_name = 'root';
diff --git a/registry/package-lock.json b/registry/package-lock.json
index 2c70918c8..6b1ff7758 100644
--- a/registry/package-lock.json
+++ b/registry/package-lock.json
@@ -64,7 +64,7 @@
"cross-env": "10.1.0",
"jsonwebtoken": "^9.0.2",
"mocha": "^11.7.5",
- "nock": "^14.0.10",
+ "nock": "^14.0.11",
"nodemon": "^3.1.11",
"nyc": "^17.1.0",
"oauth2-mock-server": "^8.2.0",
@@ -732,9 +732,9 @@
}
},
"node_modules/@mswjs/interceptors": {
- "version": "0.39.8",
- "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz",
- "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==",
+ "version": "0.41.3",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
+ "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6646,13 +6646,13 @@
"license": "MIT"
},
"node_modules/nock": {
- "version": "14.0.10",
- "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz",
- "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==",
+ "version": "14.0.11",
+ "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.11.tgz",
+ "integrity": "sha512-u5xUnYE+UOOBA6SpELJheMCtj2Laqx15Vl70QxKo43Wz/6nMHXS7PrEioXLjXAwhmawdEMNImwKCcPhBJWbKVw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@mswjs/interceptors": "^0.39.5",
+ "@mswjs/interceptors": "^0.41.0",
"json-stringify-safe": "^5.0.1",
"propagate": "^2.0.0"
},
diff --git a/registry/package.json b/registry/package.json
index b43abc1a5..cfb1e1c7c 100644
--- a/registry/package.json
+++ b/registry/package.json
@@ -51,7 +51,7 @@
"cross-env": "10.1.0",
"jsonwebtoken": "^9.0.2",
"mocha": "^11.7.5",
- "nock": "^14.0.10",
+ "nock": "^14.0.11",
"nodemon": "^3.1.11",
"nyc": "^17.1.0",
"oauth2-mock-server": "^8.2.0",
diff --git a/registry/server/appRoutes/interfaces/index.ts b/registry/server/appRoutes/interfaces/index.ts
index cdde3fff4..76c2121f3 100644
--- a/registry/server/appRoutes/interfaces/index.ts
+++ b/registry/server/appRoutes/interfaces/index.ts
@@ -57,6 +57,7 @@ export interface AppRoute {
meta?: object | string | null;
domainId?: number | null;
domainIdIdxble?: number | null;
+ domainAlias?: string | null;
namespace?: string | null;
}
@@ -68,6 +69,7 @@ export type AppRouteDto = {
templateName: string | null;
slots: Record;
domainId: number | null;
+ domainAlias: string | null;
meta: Record;
versionId: string;
namespace: string | null;
@@ -80,7 +82,12 @@ const commonAppRoute = {
next: Joi.bool().default(false),
templateName: templateNameSchema.allow(null).default(null),
slots: Joi.object().pattern(commonAppRouteSlot.name, appRouteSlotSchema).default({}),
- domainId: Joi.number().default(null),
+ domainId: Joi.number(),
+ domainAlias: Joi.string()
+ .lowercase()
+ .pattern(/^[a-z0-9-]+$/)
+ .max(64)
+ .trim(),
meta: Joi.object().default({}),
versionId: Joi.string().strip(),
namespace: Joi.string().default(null),
@@ -88,7 +95,7 @@ const commonAppRoute = {
export const partialAppRouteSchema = Joi.object({
...commonAppRoute,
-});
+}).oxor('domainId', 'domainAlias');
const conditionSpecialRole = {
is: Joi.exist(),
@@ -108,4 +115,4 @@ export const appRouteSchema = Joi.object({
is: Joi.exist(),
then: Joi.forbidden(),
}),
-});
+}).oxor('domainId', 'domainAlias');
diff --git a/registry/server/appRoutes/routes/RoutesService.ts b/registry/server/appRoutes/routes/RoutesService.ts
index c678ddd83..0cc5ef9d2 100644
--- a/registry/server/appRoutes/routes/RoutesService.ts
+++ b/registry/server/appRoutes/routes/RoutesService.ts
@@ -2,11 +2,12 @@ import { Knex } from 'knex';
import { User } from '../../../typings/User';
import db, { type VersionedKnex } from '../../db';
import { Tables } from '../../db/structure';
-import { extractInsertedId, PG_UNIQUE_VIOLATION_CODE } from '../../util/db';
+import { extractInsertedId, isUniqueConstraintError } from '../../util/db';
import { appendDigest } from '../../util/hmac';
import { EntityTypes, VersionedRecord } from '../../versioning/interfaces';
import { AppRoute, AppRouteDto, appRouteSchema, AppRouteSlot } from '../interfaces';
import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute';
+import { resolveDomainAlias } from '../services/resolveDomainAlias';
export type AppRouteWithSlot = VersionedRecord;
@@ -49,10 +50,11 @@ export class RoutesService {
* @returns routeId
*/
public async upsert(params: unknown, user: User, trxProvider: Knex.TransactionProvider): Promise {
- const { slots, ...appRoute } = await appRouteSchema.validateAsync(params, {
+ const { slots, ...validated } = await appRouteSchema.validateAsync(params, {
noDefaults: false,
externals: false,
});
+ const appRoute = await resolveDomainAlias(validated);
let savedAppRouteId: number;
const appRouteRecord = prepareAppRouteToSave(appRoute);
@@ -104,13 +106,11 @@ export class RoutesService {
);
}
- public isOrderPosError(error: any) {
- const sqliteErrorOrderPos = 'UNIQUE constraint failed: routes.orderPos, routes.domainIdIdxble';
- const constraint = 'routes_orderpos_and_domainIdIdxble_unique';
- return (
- (error.code === PG_UNIQUE_VIOLATION_CODE && error.constraint === constraint) ||
- error?.message.includes(sqliteErrorOrderPos) ||
- error?.message.includes(constraint)
+ public isOrderPosError(error: unknown) {
+ return isUniqueConstraintError(
+ error,
+ 'routes_orderpos_and_domainIdIdxble_unique',
+ 'routes.orderPos, routes.domainIdIdxble',
);
}
diff --git a/registry/server/appRoutes/routes/createAppRoute.ts b/registry/server/appRoutes/routes/createAppRoute.ts
index 6a960047f..b50c42485 100644
--- a/registry/server/appRoutes/routes/createAppRoute.ts
+++ b/registry/server/appRoutes/routes/createAppRoute.ts
@@ -4,8 +4,9 @@ import validateRequestFactory from '../../common/services/validateRequest';
import db from '../../db';
import { extractInsertedId, handleForeignConstraintError } from '../../util/db';
import { defined, getJoiErr, joiErrorToResponse } from '../../util/helpers';
-import { appRouteSchema } from '../interfaces';
+import { AppRouteDto, appRouteSchema } from '../interfaces';
import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute';
+import { resolveDomainAlias } from '../services/resolveDomainAlias';
import { transformSpecialRoutesForDB } from '../services/transformSpecialRoutes';
import { retrieveAppRouteFromDB } from './getAppRoute';
import { routesService } from './RoutesService';
@@ -17,13 +18,14 @@ const validateRequestBeforeCreateAppRoute = validateRequestFactory([
},
]);
-const createAppRoute = async (req: Request, res: Response) => {
+const createAppRoute = async (req: Request, res: Response) => {
const { slots: appRouteSlots, ...appRouteData } = req.body;
- const appRoute = transformSpecialRoutesForDB(appRouteData);
+ const domainResolved = await resolveDomainAlias(appRouteData);
+ const appRoute = transformSpecialRoutesForDB(domainResolved);
if (appRouteData.specialRole) {
- const existingRoute = await db.first().from('routes').where({
+ const existingRoute = await db('routes').first().where({
route: appRoute.route,
domainId: appRoute.domainId,
});
diff --git a/registry/server/appRoutes/routes/updateAppRoute.ts b/registry/server/appRoutes/routes/updateAppRoute.ts
index 78914c28c..8fc36d50d 100644
--- a/registry/server/appRoutes/routes/updateAppRoute.ts
+++ b/registry/server/appRoutes/routes/updateAppRoute.ts
@@ -8,7 +8,8 @@ import {
prepareAppRouteToRespond,
prepareAppRouteToSave,
} from '../services/prepareAppRoute';
-import { partialAppRouteSchema } from '../interfaces';
+import { resolveDomainAlias } from '../services/resolveDomainAlias';
+import { AppRouteDto, partialAppRouteSchema } from '../interfaces';
import { appRouteIdSchema } from '../interfaces';
import { transformSpecialRoutesForDB } from '../services/transformSpecialRoutes';
import { routesService, RoutesService } from './RoutesService';
@@ -30,11 +31,12 @@ const validateRequestBeforeUpdateAppRoute = validateRequestFactory([
},
]);
-const updateAppRoute = async (req: Request, res: Response) => {
+const updateAppRoute = async (req: Request, res: Response) => {
const { slots: appRouteSlots, ...appRouteData } = req.body;
const appRouteId = +req.params.id;
- const appRoute = transformSpecialRoutesForDB(appRouteData);
+ const domainResolved = await resolveDomainAlias(appRouteData);
+ const appRoute = transformSpecialRoutesForDB(domainResolved);
const countToUpdate = await db('routes').where('id', appRouteId);
if (!countToUpdate.length) {
diff --git a/registry/server/appRoutes/services/resolveDomainAlias.ts b/registry/server/appRoutes/services/resolveDomainAlias.ts
new file mode 100644
index 000000000..02cc52888
--- /dev/null
+++ b/registry/server/appRoutes/services/resolveDomainAlias.ts
@@ -0,0 +1,24 @@
+import db from '../../db';
+import { getJoiErr } from '../../util/helpers';
+
+/**
+ * If `domainAlias` is provided, resolves it to a `domainId` by looking up router_domains.
+ * Returns the route data with `domainId` set and `domainAlias` removed.
+ * Throws a Joi-style error if the alias doesn't match any router domain.
+ */
+export async function resolveDomainAlias(
+ appRoute: T,
+): Promise {
+ const { domainAlias, domainId, ...rest } = appRoute;
+
+ if (!domainAlias) {
+ return { ...rest, domainId: domainId ?? null } as T;
+ }
+
+ const domain = await db('router_domains').first('id').where({ alias: domainAlias });
+ if (!domain) {
+ throw getJoiErr('domainAlias', `Router domain with alias "${domainAlias}" does not exist`);
+ }
+
+ return { ...rest, domainId: domain.id } as T;
+}
diff --git a/registry/server/appRoutes/services/transformSpecialRoutes.ts b/registry/server/appRoutes/services/transformSpecialRoutes.ts
index 75b9887e6..49cec028a 100644
--- a/registry/server/appRoutes/services/transformSpecialRoutes.ts
+++ b/registry/server/appRoutes/services/transformSpecialRoutes.ts
@@ -1,9 +1,11 @@
-import { AppRouteDto } from '../interfaces';
import { AppRouteWithSlot } from '../routes/RoutesService';
export const SPECIAL_PREFIX = 'special:';
+
export const isSpecialRoute = (route: string) => route.startsWith(SPECIAL_PREFIX);
+
export const makeSpecialRoute = (specialRole: string) => `${SPECIAL_PREFIX}${specialRole}`;
+
const getSpecialRole = (route: string) => route.replace(SPECIAL_PREFIX, '');
export type ConsumerRoute = Omit & {
@@ -32,14 +34,18 @@ export function transformSpecialRoutesForConsumer(
return Array.isArray(appRoutes) ? appRoutes.map(handleRoute) : handleRoute(appRoutes);
}
-export function transformSpecialRoutesForDB({ specialRole, ...appRouteData }: AppRouteDto): AppRouteDto {
+export function transformSpecialRoutesForDB<
+ T extends { specialRole?: string; orderPos?: number | null; route?: string },
+>(appRoute: T): T {
+ const { specialRole, ...rest } = appRoute;
+
if (!specialRole) {
- return appRouteData;
+ return rest as T;
}
return {
- ...appRouteData,
+ ...rest,
orderPos: null,
route: makeSpecialRoute(specialRole),
- };
+ } as T;
}
diff --git a/registry/server/errorHandler/index.ts b/registry/server/errorHandler/index.ts
index 68b06f17b..ce1517b7d 100644
--- a/registry/server/errorHandler/index.ts
+++ b/registry/server/errorHandler/index.ts
@@ -1,8 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { Request, Response, NextFunction } from 'express';
+import Joi from 'joi';
import * as httpErrors from './httpErrors';
import noticeError from './noticeError';
+import { joiErrorToResponse } from '../util/helpers';
async function errorHandler(error: Error, req: Request, res: Response, next: NextFunction): Promise {
if (error instanceof httpErrors.NotFoundError) {
@@ -10,6 +12,11 @@ async function errorHandler(error: Error, req: Request, res: Response, next: Nex
return;
}
+ if (error instanceof Joi.ValidationError) {
+ res.status(422).send(joiErrorToResponse(error));
+ return;
+ }
+
if (error instanceof httpErrors.UnprocessableContent) {
res.status(422).send(error.message);
return;
diff --git a/registry/server/migrations/20201013140633_settings_default.ts b/registry/server/migrations/20201013140633_settings_default.ts
index 84a1765c0..119cbc96d 100644
--- a/registry/server/migrations/20201013140633_settings_default.ts
+++ b/registry/server/migrations/20201013140633_settings_default.ts
@@ -50,7 +50,7 @@ export async function up(knex: Knex): Promise {
},
{
key: SettingKeys.AuthOpenIdDiscoveryUrl,
- value: 'http://localhost:8080',
+ value: 'http://localhost:8085',
default: '',
scope: Scope.Registry,
secret: false,
diff --git a/registry/server/migrations/20260216120000_add_alias_to_router_domains.ts b/registry/server/migrations/20260216120000_add_alias_to_router_domains.ts
new file mode 100644
index 000000000..3df505e67
--- /dev/null
+++ b/registry/server/migrations/20260216120000_add_alias_to_router_domains.ts
@@ -0,0 +1,13 @@
+import type { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise {
+ return knex.schema.alterTable('router_domains', (table) => {
+ table.string('alias', 64).nullable().unique().comment('Optional short code name alias for the router domain.');
+ });
+}
+
+export async function down(knex: Knex): Promise {
+ return knex.schema.alterTable('router_domains', (table) => {
+ table.dropColumn('alias');
+ });
+}
diff --git a/registry/server/routerDomains/interfaces/index.ts b/registry/server/routerDomains/interfaces/index.ts
index 075d31a44..48e2e854b 100644
--- a/registry/server/routerDomains/interfaces/index.ts
+++ b/registry/server/routerDomains/interfaces/index.ts
@@ -7,8 +7,9 @@ export default interface RouterDomains {
domainName: string;
template500?: string;
canonicalDomain?: string | null;
- props?: Record | null;
- ssrProps?: Record | null;
+ alias?: string | null;
+ props?: Record | string | null;
+ ssrProps?: Record | string | null;
}
export const routerDomainIdSchema = Joi.string().trim().required();
@@ -25,6 +26,13 @@ const commonRouterDomainsSchema = {
domainName: domainValidation().required(),
template500: templateNameSchema.required(),
canonicalDomain: domainValidation().allow(null).default(null),
+ alias: Joi.string()
+ .lowercase()
+ .pattern(/^[a-z0-9-]+$/)
+ .max(64)
+ .trim()
+ .allow(null)
+ .default(null),
props: Joi.object().allow(null).default(null),
ssrProps: Joi.object().allow(null).default(null),
versionId: Joi.string().strip(),
diff --git a/registry/server/routerDomains/routes/createRouterDomains.ts b/registry/server/routerDomains/routes/createRouterDomains.ts
index c11168296..b23b10c24 100644
--- a/registry/server/routerDomains/routes/createRouterDomains.ts
+++ b/registry/server/routerDomains/routes/createRouterDomains.ts
@@ -4,8 +4,8 @@ import db from '../../db';
import validateRequestFactory from '../../common/services/validateRequest';
import preProcessResponse from '../../common/services/preProcessResponse';
import RouterDomains, { routerDomainsSchema } from '../interfaces';
-import { extractInsertedId } from '../../util/db';
-import { defined } from '../../util/helpers';
+import { extractInsertedId, isUniqueConstraintError } from '../../util/db';
+import { defined, getJoiErr } from '../../util/helpers';
import { stringifyJSON } from '../../common/services/json';
const validateRequest = validateRequestFactory([
@@ -15,15 +15,25 @@ const validateRequest = validateRequestFactory([
},
]);
-const createRouterDomains = async (req: Request, res: Response): Promise => {
+const createRouterDomains = async (
+ req: Request>,
+ res: Response,
+): Promise => {
let routerDomainId: number | undefined;
- await db.versioning(req.user, { type: 'router_domains' }, async (trx) => {
- const result = await db('router_domains')
- .insert(stringifyJSON(['props', 'ssrProps'], req.body), 'id')
- .transacting(trx);
- routerDomainId = extractInsertedId(result);
- return routerDomainId;
- });
+ try {
+ await db.versioning(req.user, { type: 'router_domains' }, async (trx) => {
+ const result = await db('router_domains')
+ .insert(stringifyJSON(['props', 'ssrProps'], req.body), 'id')
+ .transacting(trx);
+ routerDomainId = extractInsertedId(result);
+ return routerDomainId;
+ });
+ } catch (e: unknown) {
+ if (isUniqueConstraintError(e, 'router_domains_alias_unique', 'router_domains.alias')) {
+ throw getJoiErr('alias', `Router domain with alias "${req.body.alias}" already exists.`);
+ }
+ throw e;
+ }
const [savedRouterDomains] = await db
.select()
diff --git a/registry/server/routerDomains/routes/updateRouterDomains.ts b/registry/server/routerDomains/routes/updateRouterDomains.ts
index 490c05e58..878af7432 100644
--- a/registry/server/routerDomains/routes/updateRouterDomains.ts
+++ b/registry/server/routerDomains/routes/updateRouterDomains.ts
@@ -6,6 +6,8 @@ import validateRequestFactory from '../../common/services/validateRequest';
import preProcessResponse from '../../common/services/preProcessResponse';
import { stringifyJSON } from '../../common/services/json';
import RouterDomains, { routerDomainIdSchema, partialRouterDomainsSchema } from '../interfaces';
+import { isUniqueConstraintError } from '../../util/db';
+import { getJoiErr } from '../../util/helpers';
type RequestParams = {
id: string;
@@ -24,25 +26,33 @@ const validateRequest = validateRequestFactory([
},
]);
-const updateRouterDomains = async (req: Request, res: Response): Promise => {
+const updateRouterDomains = async (
+ req: Request>,
+ res: Response,
+): Promise => {
const routerDomainId = req.params.id;
- const countToUpdate = await db('router_domains').where({
- id: routerDomainId,
- });
+ const countToUpdate = await db('router_domains').where('id', routerDomainId);
if (!countToUpdate.length) {
res.status(404).send('Not found');
return;
}
- await db.versioning(req.user, { type: 'router_domains', id: routerDomainId }, async (trx) => {
- await db('router_domains')
- .where({ id: routerDomainId })
- .update(stringifyJSON(['props', 'ssrProps'], req.body))
- .transacting(trx);
- });
+ try {
+ await db.versioning(req.user, { type: 'router_domains', id: routerDomainId }, async (trx) => {
+ await db('router_domains')
+ .where('id', routerDomainId)
+ .update(stringifyJSON(['props', 'ssrProps'], req.body))
+ .transacting(trx);
+ });
+ } catch (e: unknown) {
+ if (isUniqueConstraintError(e, 'router_domains_alias_unique', 'router_domains.alias')) {
+ throw getJoiErr('alias', `Router domain with alias "${req.body.alias}" already exists.`);
+ }
+ throw e;
+ }
- const [updatedRouterDomains] = await db.select().from('router_domains').where('id', routerDomainId);
+ const [updatedRouterDomains] = await db('router_domains').select().where('id', routerDomainId);
res.status(200).send(preProcessResponse(updatedRouterDomains));
};
diff --git a/registry/server/util/db.ts b/registry/server/util/db.ts
index c09bae0f2..74bdf8fe0 100644
--- a/registry/server/util/db.ts
+++ b/registry/server/util/db.ts
@@ -27,6 +27,28 @@ export function formatDate(date: Date): string {
return date.toISOString().slice(0, 19).replace('T', ' ');
}
+/**
+ * Returns true when `err` is a unique-constraint violation for the given constraint.
+ *
+ * @param constraint Knex-generated constraint name (e.g. `router_domains_alias_unique`).
+ * Used for PostgreSQL (code 23505 + constraint field) and MySQL / MariaDB
+ * (constraint name appears in the error message).
+ * @param sqliteTableCols SQLite reports violations as `UNIQUE constraint failed: .`.
+ * Pass the table.column string (e.g. `router_domains.alias`) so SQLite errors
+ * are also matched. For composite indexes use a comma-separated list
+ * matching the SQLite format exactly (e.g. `routes.orderPos, routes.domainIdIdxble`).
+ */
+export function isUniqueConstraintError(err: unknown, constraint: string, sqliteTableCols?: string): boolean {
+ const e = err as Record;
+ const msgIncludes = (s: string) => typeof e?.message === 'string' && (e.message as string).includes(s);
+ return (
+ (e?.code === PG_UNIQUE_VIOLATION_CODE && e?.constraint === constraint) ||
+ msgIncludes(`UNIQUE constraint failed: ${constraint}`) ||
+ msgIncludes(constraint) ||
+ (!!sqliteTableCols && msgIncludes(`UNIQUE constraint failed: ${sqliteTableCols}`))
+ );
+}
+
export function handleForeignConstraintError(err: Error) {
if (
['foreign key constraint fails', 'FOREIGN KEY constraint failed', 'violates foreign key constraint'].some((v) =>
diff --git a/registry/tests/appRoutes.spec.ts b/registry/tests/appRoutes.spec.ts
index 356b355a1..10150db5f 100644
--- a/registry/tests/appRoutes.spec.ts
+++ b/registry/tests/appRoutes.spec.ts
@@ -459,6 +459,71 @@ describe(`Tests ${example.url}`, () => {
}
});
+ it('should create record with domainAlias', async () => {
+ let domainId, routeId;
+
+ try {
+ const domainResponse = await req
+ .post(example.routerDomain.url)
+ .send({ ...example.routerDomain.correct, alias: 'my-test-domain' });
+ domainId = domainResponse.body.id;
+
+ let response = await req
+ .post(example.url)
+ .send({ ...example.correct, domainAlias: 'my-test-domain' })
+ .expect(200);
+
+ routeId = response.body.id;
+
+ expect(response.body.domainId).to.equal(domainId);
+
+ response = await req.get(example.url + routeId).expect(200);
+
+ expect(response.body.domainId).to.equal(domainId);
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ domainId && (await req.delete(example.routerDomain.url + domainId));
+ }
+ });
+
+ it('should not create record with non-existing domainAlias', async () => {
+ let routeId;
+
+ try {
+ const response = await req.post(example.url).send({ ...example.correct, domainAlias: 'non-existing' });
+
+ if (response.body.id) {
+ routeId = response.body.id;
+ }
+
+ expect(response.status).equal(422);
+ expect(response.text).to.include('Router domain with alias "non-existing" does not exist');
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ }
+ });
+
+ it('should not create record with both domainId and domainAlias', async () => {
+ let routeId;
+
+ try {
+ const response = await req.post(example.url).send({
+ ...example.correct,
+ domainId: 1,
+ domainAlias: 'some-alias',
+ });
+
+ if (response.body.id) {
+ routeId = response.body.id;
+ }
+
+ expect(response.status).equal(422);
+ expect(response.text).to.include('contains a conflict');
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ }
+ });
+
it('should create special record with existing domainId', async () => {
let domainId, routeId;
@@ -929,6 +994,77 @@ describe(`Tests ${example.url}`, () => {
}
});
+ it('should update record with domainAlias', async () => {
+ let domainId1, domainId2, routeId;
+
+ try {
+ const domainResponse1 = await req
+ .post(example.routerDomain.url)
+ .send({ ...example.routerDomain.correct, alias: 'update-domain-1' });
+ domainId1 = domainResponse1.body.id;
+
+ const domainResponse2 = await req
+ .post(example.routerDomain.url)
+ .send({ ...example.routerDomain.correct, alias: 'update-domain-2' });
+ domainId2 = domainResponse2.body.id;
+
+ let response = await req
+ .post(example.url)
+ .send({ ...example.correct, domainId: domainId1 })
+ .expect(200);
+ routeId = response.body.id;
+
+ response = await req
+ .put(example.url + routeId)
+ .send({ ...example.updated, domainAlias: 'update-domain-2' })
+ .expect(200);
+
+ expect(response.body.domainId).to.equal(domainId2);
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ domainId1 && (await req.delete(example.routerDomain.url + domainId1));
+ domainId2 && (await req.delete(example.routerDomain.url + domainId2));
+ }
+ });
+
+ it('should not update record with non-existing domainAlias', async () => {
+ let routeId;
+
+ try {
+ let response = await req.post(example.url).send(example.correct).expect(200);
+ routeId = response.body.id;
+
+ response = await req
+ .put(example.url + routeId)
+ .send({ ...example.updated, domainAlias: 'non-existing' });
+
+ expect(response.status).equal(422);
+ expect(response.text).to.include('Router domain with alias "non-existing" does not exist');
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ }
+ });
+
+ it('should not update record with both domainId and domainAlias', async () => {
+ let routeId;
+
+ try {
+ let response = await req.post(example.url).send(example.correct).expect(200);
+ routeId = response.body.id;
+
+ response = await req.put(example.url + routeId).send({
+ ...example.updated,
+ domainId: 1,
+ domainAlias: 'some-alias',
+ });
+
+ expect(response.status).equal(422);
+ expect(response.text).to.include('contains a conflict');
+ } finally {
+ routeId && (await req.delete(example.url + routeId));
+ }
+ });
+
it('should successfully update record with metadata', async () => {
let routeId;
diff --git a/registry/tests/config.spec.ts b/registry/tests/config.spec.ts
index ce5294045..6e0216b01 100644
--- a/registry/tests/config.spec.ts
+++ b/registry/tests/config.spec.ts
@@ -1063,5 +1063,63 @@ describe('Tests /api/v1/config', () => {
await req.delete('/api/v1/template/' + example.templates.name);
}
});
+ it('should upsert route with domainAlias', async () => {
+ let domainId: number | undefined;
+ try {
+ await req.post('/api/v1/template/').send(example.templates).expect(200);
+ const domainResponse = await req
+ .post('/api/v1/router_domains/')
+ .send({ ...example.routerDomains, alias: 'config-domain' })
+ .expect(200);
+ domainId = domainResponse.body.id;
+
+ await req
+ .put('/api/v1/config')
+ .send({
+ apps: [{ ...app, name: 'app-alias' }],
+ routes: [
+ {
+ ...appRoute('app-alias'),
+ domainAlias: 'config-domain',
+ },
+ ],
+ })
+ .expect(204);
+
+ const { body: config } = await req
+ .get('/api/v1/config')
+ .query({ domainName: example.routerDomains.domainName })
+ .expect(200);
+
+ expect(config.routes).to.have.lengthOf(1);
+ expect(config.routes[0]).to.deep.include({
+ route: '/app-alias/*',
+ domain: example.routerDomains.domainName,
+ });
+ } finally {
+ const { body: routesWithId } = await req.get('/api/v1/route');
+ await Promise.all(routesWithId.map((x: any) => req.delete(`/api/v1/route/${x.id}`)));
+ await req.delete('/api/v1/app/app-alias');
+ domainId && (await req.delete(`/api/v1/router_domains/${domainId}`));
+ await req.delete('/api/v1/template/' + example.templates.name);
+ }
+ });
+ it('should not upsert route with non-existing domainAlias', async () => {
+ await req
+ .put('/api/v1/config')
+ .send({
+ apps: [{ ...app, name: 'app-alias-fail' }],
+ routes: [
+ {
+ ...appRoute('app-alias-fail'),
+ domainAlias: 'non-existing',
+ },
+ ],
+ })
+ .expect(422);
+
+ const { body: config } = await req.get('/api/v1/config').expect(200);
+ expect(config.apps['app-alias-fail']).to.be.undefined;
+ });
});
});
diff --git a/registry/tests/routerDomains.spec.ts b/registry/tests/routerDomains.spec.ts
index fbeeae365..0edf9f4e9 100644
--- a/registry/tests/routerDomains.spec.ts
+++ b/registry/tests/routerDomains.spec.ts
@@ -36,6 +36,26 @@ const example = {
},
ssrProps: null,
}),
+ withBrandId: Object.freeze({
+ domainName: 'domainWithBrand.com',
+ template500: 'testTemplate500',
+ brandId: 'testbrand',
+ }),
+ withBrandIdUpdated: Object.freeze({
+ domainName: 'domainWithBrand.com',
+ template500: 'testTemplate500',
+ brandId: 'updatedbrand',
+ }),
+ withAlias: Object.freeze({
+ domainName: 'domainWithAlias.com',
+ template500: 'testTemplate500',
+ alias: 'mydomain',
+ }),
+ withAliasUpdated: Object.freeze({
+ domainName: 'domainWithAlias.com',
+ template500: 'testTemplate500',
+ alias: 'updated-alias',
+ }),
};
describe(`Tests ${example.url}`, () => {
@@ -134,6 +154,74 @@ describe(`Tests ${example.url}`, () => {
}
});
+ it('should successfully create record with alias', async () => {
+ let routerDomainsId;
+
+ try {
+ const responseCreation = await req.post(example.url).send(example.withAlias).expect(200);
+
+ routerDomainsId = responseCreation.body.id;
+
+ expect(responseCreation.body).deep.equal({
+ id: routerDomainsId,
+ ...example.withAlias,
+ });
+
+ const responseFetching = await req.get(example.url + routerDomainsId).expect(200);
+
+ expect(responseFetching.body.alias).to.equal('mydomain');
+ } finally {
+ routerDomainsId && (await req.delete(example.url + routerDomainsId));
+ }
+ });
+
+ it('should lowercase alias on create', async () => {
+ let routerDomainsId;
+
+ try {
+ const responseCreation = await req
+ .post(example.url)
+ .send({ ...example.correct, alias: 'MyDomain' })
+ .expect(200);
+
+ routerDomainsId = responseCreation.body.id;
+
+ expect(responseCreation.body.alias).to.equal('mydomain');
+ } finally {
+ routerDomainsId && (await req.delete(example.url + routerDomainsId));
+ }
+ });
+
+ it('should not create record with invalid alias characters', async () => {
+ await req
+ .post(example.url)
+ .send({ ...example.correct, alias: 'invalid_alias!' })
+ .expect(422);
+ });
+
+ it('should not create record with alias exceeding max length', async () => {
+ await req
+ .post(example.url)
+ .send({ ...example.correct, alias: 'a'.repeat(65) })
+ .expect(422);
+ });
+
+ it('should not create record with a duplicate alias', async () => {
+ let firstId: number | undefined;
+
+ try {
+ const first = await req.post(example.url).send(example.withAlias).expect(200);
+ firstId = first.body.id;
+
+ await req
+ .post(example.url)
+ .send({ ...example.correct, domainName: 'another-domain.com', alias: example.withAlias.alias })
+ .expect(422, `Router domain with alias "${example.withAlias.alias}" already exists.`);
+ } finally {
+ firstId && (await req.delete(example.url + firstId));
+ }
+ });
+
it('should create record with empty props', async () => {
let routerDomainsId;
const withEmptyProps = {
@@ -393,6 +481,27 @@ describe(`Tests ${example.url}`, () => {
}
});
+ it('should not update record with an alias already used by another domain', async () => {
+ let firstId: number | undefined;
+ let secondId: number | undefined;
+
+ try {
+ const first = await req.post(example.url).send(example.withAlias).expect(200);
+ firstId = first.body.id;
+
+ const second = await req.post(example.url).send(example.updated).expect(200);
+ secondId = second.body.id;
+
+ await req
+ .put(example.url + secondId)
+ .send({ ...example.updated, alias: example.withAlias.alias })
+ .expect(422, `Router domain with alias "${example.withAlias.alias}" already exists.`);
+ } finally {
+ firstId && (await req.delete(example.url + firstId));
+ secondId && (await req.delete(example.url + secondId));
+ }
+ });
+
it('should handle nested objects in props', async () => {
let routerDomainsId;
const complexProps = {
diff --git a/registry/tests/settings.spec.ts b/registry/tests/settings.spec.ts
index 5615f4034..e1eb3cd30 100644
--- a/registry/tests/settings.spec.ts
+++ b/registry/tests/settings.spec.ts
@@ -140,7 +140,7 @@ describe(url, () => {
chai.expect(response.body).to.deep.include({
key: SettingKeys.AuthOpenIdDiscoveryUrl,
- value: 'http://localhost:8080',
+ value: 'http://localhost:8085',
scope: Scope.Registry,
secret: false,
meta: {
diff --git a/registry/typings/knex/tables.d.ts b/registry/typings/knex/tables.d.ts
index a76849229..93c8bd8d3 100644
--- a/registry/typings/knex/tables.d.ts
+++ b/registry/typings/knex/tables.d.ts
@@ -3,6 +3,7 @@ import { AppRoute, AppRouteSlot } from '../../server/appRoutes/interfaces';
import { App } from '../../server/apps/interfaces';
import { DomainSetting, SettingRaw } from '../../server/settings/interfaces';
import { SharedLib } from '../../server/sharedLibs/interfaces';
+import RouterDomains from '../../server/routerDomains/interfaces';
type Optional = Partial> & Omit;
@@ -14,6 +15,7 @@ declare module 'knex/types/tables' {
shared_libs: Knex.CompositeTableType;
settings: Knex.CompositeTableType;
settings_domain_value: Knex.CompositeTableType>;
+ router_domains: Knex.CompositeTableType>;
// TODO add remaining tables
}
}