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 } }