Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions docs/multi-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -172,19 +171,53 @@ 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.

### Client-side behavior

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.
15 changes: 13 additions & 2 deletions registry/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<link>` 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. |
1 change: 1 addition & 0 deletions registry/client/src/routerDomains/Edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const InputForm = ({ mode = 'edit', ...props }) => {
<SelectInput resettable optionText="name" />
</ReferenceInput>
<TextInput source="canonicalDomain" label="Canonical Domain" fullWidth />
<TextInput source="alias" label="Alias" fullWidth />
</FormTab>

<FormTab label="Domain Props">
Expand Down
1 change: 1 addition & 0 deletions registry/client/src/routerDomains/List.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const PostList = (props) => {
<TextField source="name" />
</ReferenceField>
<TextField source="canonicalDomain" sortable={false} emptyText="-" label="Canonical Domain" />
<TextField source="alias" sortable={false} emptyText="-" label="Alias" />
<ListActionsToolbar>
<EditButton />
</ListActionsToolbar>
Expand Down
4 changes: 2 additions & 2 deletions registry/lde/oauth-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
16 changes: 8 additions & 8 deletions registry/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions registry/server/appRoutes/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface AppRoute {
meta?: object | string | null;
domainId?: number | null;
domainIdIdxble?: number | null;
domainAlias?: string | null;
namespace?: string | null;
}

Expand All @@ -68,6 +69,7 @@ export type AppRouteDto = {
templateName: string | null;
slots: Record<string, AppRouteSlotDto>;
domainId: number | null;
domainAlias: string | null;
meta: Record<string, any>;
versionId: string;
namespace: string | null;
Expand All @@ -80,15 +82,20 @@ 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),
};

export const partialAppRouteSchema = Joi.object({
...commonAppRoute,
});
}).oxor('domainId', 'domainAlias');

const conditionSpecialRole = {
is: Joi.exist(),
Expand All @@ -108,4 +115,4 @@ export const appRouteSchema = Joi.object<AppRouteDto>({
is: Joi.exist(),
then: Joi.forbidden(),
}),
});
}).oxor('domainId', 'domainAlias');
18 changes: 9 additions & 9 deletions registry/server/appRoutes/routes/RoutesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppRoute & AppRouteSlot>;

Expand Down Expand Up @@ -49,10 +50,11 @@ export class RoutesService {
* @returns routeId
*/
public async upsert(params: unknown, user: User, trxProvider: Knex.TransactionProvider): Promise<AppRoute> {
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);
Expand Down Expand Up @@ -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',
);
}

Expand Down
10 changes: 6 additions & 4 deletions registry/server/appRoutes/routes/createAppRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,13 +18,14 @@ const validateRequestBeforeCreateAppRoute = validateRequestFactory([
},
]);

const createAppRoute = async (req: Request, res: Response) => {
const createAppRoute = async (req: Request<unknown, unknown, AppRouteDto>, 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,
});
Expand Down
8 changes: 5 additions & 3 deletions registry/server/appRoutes/routes/updateAppRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,11 +31,12 @@ const validateRequestBeforeUpdateAppRoute = validateRequestFactory([
},
]);

const updateAppRoute = async (req: Request<UpdateAppRouteRequestParams>, res: Response) => {
const updateAppRoute = async (req: Request<UpdateAppRouteRequestParams, unknown, AppRouteDto>, 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) {
Expand Down
24 changes: 24 additions & 0 deletions registry/server/appRoutes/services/resolveDomainAlias.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { domainId?: number | null; domainAlias?: string | null }>(
appRoute: T,
): Promise<T> {
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;
}
16 changes: 11 additions & 5 deletions registry/server/appRoutes/services/transformSpecialRoutes.ts
Original file line number Diff line number Diff line change
@@ -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<AppRouteWithSlot, 'route' | 'next'> & {
Expand Down Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions registry/server/errorHandler/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
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<void> {
if (error instanceof httpErrors.NotFoundError) {
res.status(404).send('Not found');
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;
Expand Down
Loading
Loading