Skip to content

Commit 6e0a150

Browse files
committed
feat(router-domains): add domain alias support for router domains
- Introduced an `alias` field for router domains to provide a stable, human-readable identifier. - Updated API to accept `domainAlias` in route creation and updates, resolving it to `domainId`. - Added validation to ensure unique aliases across router domains. - Updated documentation to reflect changes in router domain configuration and usage. - Modified tests to cover scenarios for creating and updating router domains with aliases.
1 parent 2a0c28f commit 6e0a150

25 files changed

+521
-71
lines changed

docs/multi-domains.md

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ ILC can handle requests from multiple domains so that you don't need to roll out
3434
1. In the top right corner, click **+ Create special route**.
3535

3636
**In the general tab:**
37-
3837
1. In the **Special role** dropdown, select **404**.
3938
1. In the **Domain** dropdown, select your domain.
4039

@@ -172,19 +171,53 @@ When canonical domain is set, ILC generates canonical tags using the canonical d
172171

173172
When both route `canonicalUrl` (in route metadata) and domain `canonicalDomain` are set, they combine:
174173

175-
- Request: `https://mirror.example.com/products/variant-123`
176-
- Route metadata: `{ "canonicalUrl": "/products/main" }`
177-
- Canonical domain: `www.example.com`
178-
- Result: `https://www.example.com/products/main`
174+
- Request: `https://mirror.example.com/products/variant-123`
175+
- Route metadata: `{ "canonicalUrl": "/products/main" }`
176+
- Canonical domain: `www.example.com`
177+
- Result: `https://www.example.com/products/main`
179178

180179
See [Route metadata canonicalUrl](routing/route_configuration.md#canonicalurl) for more information.
181180

182181
### Client-side behavior
183182

184183
ILC automatically updates canonical tags during client-side navigation.
185184

185+
## Domain alias
186+
187+
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.
188+
189+
### Why it's needed
190+
191+
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`).
192+
193+
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.
194+
195+
### Configure an alias
196+
197+
1. Open the ILC Registry and navigate to **Router domains**.
198+
2. Select an existing domain or click **+ Create** to add a new one.
199+
3. In the **Alias** field, enter a short identifier using only lowercase letters, digits, and hyphens (e.g., `main-shop`). Maximum 64 characters.
200+
4. Click **Save**.
201+
202+
### Using `domainAlias` in routes
203+
204+
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.
205+
206+
```json
207+
{
208+
"route": "/checkout",
209+
"domainAlias": "main-shop",
210+
"slots": { ... }
211+
}
212+
```
213+
214+
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.
215+
216+
!!! note ""
217+
The alias must be unique across all router domains within an ILC instance.
218+
186219
## Additional information
187220

188-
- 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**.
189-
- Each registered domain in the **Router domains** has its own set of routes that do not overlap.
190-
- 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.
221+
- 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**.
222+
- Each registered domain in the **Router domains** has its own set of routes that do not overlap.
223+
- 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.

registry/client/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ $ npm install
77
$ npm start
88
```
99

10-
And then browse to [http://localhost:8080/](http://localhost:8080/).
10+
And then browse to [http://localhost:4001/](http://localhost:4001/).
1111

1212
The default credentials are:
1313
**root / pwd** - for admin access.
@@ -44,4 +44,15 @@ Or provide it to the docker container itself.
4444

4545
# Parts of UI
4646

47-
- [Router domains](./docs/multi-domains.md)
47+
- [Router domains](./docs/multi-domains.md)
48+
49+
## Router domain fields
50+
51+
| Field | Description |
52+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53+
| `domainName` | Hostname of the domain (e.g. `example.com`). Used by ILC at runtime to match incoming requests. |
54+
| `template500` | Default 500 error template for this domain. |
55+
| `canonicalDomain` | Alternative domain used for canonical `<link>` tags. |
56+
| `brandId` | Brand identifier for multi-brand setups. |
57+
| `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. |
58+
| `props` / `ssrProps` | Domain-level properties merged into all applications running on this domain. |

registry/client/src/routerDomains/Edit.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const InputForm = ({ mode = 'edit', ...props }) => {
2929
<SelectInput resettable optionText="name" />
3030
</ReferenceInput>
3131
<TextInput source="canonicalDomain" label="Canonical Domain" fullWidth />
32+
<TextInput source="alias" label="Alias" fullWidth />
3233
</FormTab>
3334

3435
<FormTab label="Domain Props">

registry/client/src/routerDomains/List.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const PostList = (props) => {
3333
<TextField source="name" />
3434
</ReferenceField>
3535
<TextField source="canonicalDomain" sortable={false} emptyText="-" label="Canonical Domain" />
36+
<TextField source="alias" sortable={false} emptyText="-" label="Alias" />
3637
<ListActionsToolbar>
3738
<EditButton />
3839
</ListActionsToolbar>

registry/lde/oauth-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { OAuth2Server } from 'oauth2-mock-server';
77
await server.issuer.keys.generate('RS256');
88

99
// Start the server
10-
await server.start(8080);
11-
console.log('Issuer URL:', server.issuer.url); // -> http://localhost:8080
10+
await server.start(8085);
11+
console.log('Issuer URL:', server.issuer.url);
1212

1313
server.service.on('beforeTokenSigning', (token, req) => {
1414
token.payload.unique_name = 'root';

registry/package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

registry/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"cross-env": "10.1.0",
5252
"jsonwebtoken": "^9.0.2",
5353
"mocha": "^11.7.5",
54-
"nock": "^14.0.10",
54+
"nock": "^14.0.11",
5555
"nodemon": "^3.1.11",
5656
"nyc": "^17.1.0",
5757
"oauth2-mock-server": "^8.2.0",

registry/server/appRoutes/interfaces/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface AppRoute {
5757
meta?: object | string | null;
5858
domainId?: number | null;
5959
domainIdIdxble?: number | null;
60+
domainAlias?: string | null;
6061
namespace?: string | null;
6162
}
6263

@@ -68,6 +69,7 @@ export type AppRouteDto = {
6869
templateName: string | null;
6970
slots: Record<string, AppRouteSlotDto>;
7071
domainId: number | null;
72+
domainAlias: string | null;
7173
meta: Record<string, any>;
7274
versionId: string;
7375
namespace: string | null;
@@ -80,15 +82,20 @@ const commonAppRoute = {
8082
next: Joi.bool().default(false),
8183
templateName: templateNameSchema.allow(null).default(null),
8284
slots: Joi.object().pattern(commonAppRouteSlot.name, appRouteSlotSchema).default({}),
83-
domainId: Joi.number().default(null),
85+
domainId: Joi.number(),
86+
domainAlias: Joi.string()
87+
.lowercase()
88+
.pattern(/^[a-z0-9-]+$/)
89+
.max(64)
90+
.trim(),
8491
meta: Joi.object().default({}),
8592
versionId: Joi.string().strip(),
8693
namespace: Joi.string().default(null),
8794
};
8895

8996
export const partialAppRouteSchema = Joi.object({
9097
...commonAppRoute,
91-
});
98+
}).oxor('domainId', 'domainAlias');
9299

93100
const conditionSpecialRole = {
94101
is: Joi.exist(),
@@ -108,4 +115,4 @@ export const appRouteSchema = Joi.object<AppRouteDto>({
108115
is: Joi.exist(),
109116
then: Joi.forbidden(),
110117
}),
111-
});
118+
}).oxor('domainId', 'domainAlias');

registry/server/appRoutes/routes/RoutesService.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { Knex } from 'knex';
22
import { User } from '../../../typings/User';
33
import db, { type VersionedKnex } from '../../db';
44
import { Tables } from '../../db/structure';
5-
import { extractInsertedId, PG_UNIQUE_VIOLATION_CODE } from '../../util/db';
5+
import { extractInsertedId, isUniqueConstraintError } from '../../util/db';
66
import { appendDigest } from '../../util/hmac';
77
import { EntityTypes, VersionedRecord } from '../../versioning/interfaces';
88
import { AppRoute, AppRouteDto, appRouteSchema, AppRouteSlot } from '../interfaces';
99
import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute';
10+
import { resolveDomainAlias } from '../services/resolveDomainAlias';
1011

1112
export type AppRouteWithSlot = VersionedRecord<AppRoute & AppRouteSlot>;
1213

@@ -49,10 +50,11 @@ export class RoutesService {
4950
* @returns routeId
5051
*/
5152
public async upsert(params: unknown, user: User, trxProvider: Knex.TransactionProvider): Promise<AppRoute> {
52-
const { slots, ...appRoute } = await appRouteSchema.validateAsync(params, {
53+
const { slots, ...validated } = await appRouteSchema.validateAsync(params, {
5354
noDefaults: false,
5455
externals: false,
5556
});
57+
const appRoute = await resolveDomainAlias(validated);
5658

5759
let savedAppRouteId: number;
5860
const appRouteRecord = prepareAppRouteToSave(appRoute);
@@ -104,14 +106,8 @@ export class RoutesService {
104106
);
105107
}
106108

107-
public isOrderPosError(error: any) {
108-
const sqliteErrorOrderPos = 'UNIQUE constraint failed: routes.orderPos, routes.domainIdIdxble';
109-
const constraint = 'routes_orderpos_and_domainIdIdxble_unique';
110-
return (
111-
(error.code === PG_UNIQUE_VIOLATION_CODE && error.constraint === constraint) ||
112-
error?.message.includes(sqliteErrorOrderPos) ||
113-
error?.message.includes(constraint)
114-
);
109+
public isOrderPosError(error: unknown) {
110+
return isUniqueConstraintError(error, 'routes_orderpos_and_domainIdIdxble_unique');
115111
}
116112

117113
public async getNextOrderPos(domainId: number | null, trx: Knex.Transaction) {

registry/server/appRoutes/routes/createAppRoute.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import validateRequestFactory from '../../common/services/validateRequest';
44
import db from '../../db';
55
import { extractInsertedId, handleForeignConstraintError } from '../../util/db';
66
import { defined, getJoiErr, joiErrorToResponse } from '../../util/helpers';
7-
import { appRouteSchema } from '../interfaces';
7+
import { AppRouteDto, appRouteSchema } from '../interfaces';
88
import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute';
9+
import { resolveDomainAlias } from '../services/resolveDomainAlias';
910
import { transformSpecialRoutesForDB } from '../services/transformSpecialRoutes';
1011
import { retrieveAppRouteFromDB } from './getAppRoute';
1112
import { routesService } from './RoutesService';
@@ -17,13 +18,14 @@ const validateRequestBeforeCreateAppRoute = validateRequestFactory([
1718
},
1819
]);
1920

20-
const createAppRoute = async (req: Request, res: Response) => {
21+
const createAppRoute = async (req: Request<unknown, unknown, AppRouteDto>, res: Response) => {
2122
const { slots: appRouteSlots, ...appRouteData } = req.body;
2223

23-
const appRoute = transformSpecialRoutesForDB(appRouteData);
24+
const domainResolved = await resolveDomainAlias(appRouteData);
25+
const appRoute = transformSpecialRoutesForDB(domainResolved);
2426

2527
if (appRouteData.specialRole) {
26-
const existingRoute = await db.first().from('routes').where({
28+
const existingRoute = await db('routes').first().where({
2729
route: appRoute.route,
2830
domainId: appRoute.domainId,
2931
});

0 commit comments

Comments
 (0)