diff --git a/docs/multi-domains.md b/docs/multi-domains.md index d97599bc2..8f1b092e3 100644 --- a/docs/multi-domains.md +++ b/docs/multi-domains.md @@ -213,6 +213,20 @@ When creating or updating a route via the API, you can supply `domainAlias` inst 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. +### Using `domainAlias` in apps + +When creating or updating an app via the API, or when upserting config via `/api/v1/config`, you can supply `domainAlias` instead of `enforceDomain`. The two fields are mutually exclusive — provide exactly one or neither. + +```json +{ + "name": "@portal/checkout-embedded", + "domainAlias": "main-shop", + "spaBundle": "https://cdn.example.com/checkout.js" +} +``` + +The Registry resolves the alias to the corresponding router domain at write time and stores it in the existing `enforceDomain` relation. This is especially useful for embedded applications that do not have their own route: the app is included only in the matching domain config, so it cannot be loaded from another domain through the ILC client APIs. + !!! note "" The alias must be unique across all router domains within an ILC instance. diff --git a/registry/server/appRoutes/routes/RoutesService.ts b/registry/server/appRoutes/routes/RoutesService.ts index 0cc5ef9d2..5ee634b45 100644 --- a/registry/server/appRoutes/routes/RoutesService.ts +++ b/registry/server/appRoutes/routes/RoutesService.ts @@ -5,12 +5,24 @@ import { Tables } from '../../db/structure'; 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 { AppRoute, appRouteSchema, AppRouteSlot } from '../interfaces'; import { prepareAppRouteSlotsToSave, prepareAppRouteToSave } from '../services/prepareAppRoute'; import { resolveDomainAlias } from '../services/resolveDomainAlias'; export type AppRouteWithSlot = VersionedRecord; +type ExistingRouteLookup = { + route: string; + domainId: number | null; + namespace: string | null; +}; + +type ExistingRouteByOrderPosLookup = { + orderPos?: number | null; + domainId: number | null; + namespace: string | null; +}; + export class RoutesService { constructor(private readonly db: VersionedKnex) {} @@ -127,7 +139,7 @@ export class RoutesService { } private async findExistingOrderPos( - appRoute: Omit, + appRoute: ExistingRouteLookup, trx: Knex.Transaction, ): Promise { const existingRoute = await this.db(Tables.Routes) @@ -138,7 +150,7 @@ export class RoutesService { } private async findExistingRouteId( - appRoute: Omit, + appRoute: ExistingRouteByOrderPosLookup, trx: Knex.Transaction, ): Promise { const existingRoute = await this.db(Tables.Routes) diff --git a/registry/server/appRoutes/services/resolveDomainAlias.ts b/registry/server/appRoutes/services/resolveDomainAlias.ts index 02cc52888..bb481b9ca 100644 --- a/registry/server/appRoutes/services/resolveDomainAlias.ts +++ b/registry/server/appRoutes/services/resolveDomainAlias.ts @@ -6,13 +6,22 @@ import { getJoiErr } from '../../util/helpers'; * 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( +type AppRouteWithDomainAlias = { + domainId?: number | null; + domainAlias?: string | null; +}; + +type ResolvedRouteDomainAlias = Omit & { + domainId: number | null; +}; + +export async function resolveDomainAlias( appRoute: T, -): Promise { +): Promise> { const { domainAlias, domainId, ...rest } = appRoute; if (!domainAlias) { - return { ...rest, domainId: domainId ?? null } as T; + return { ...rest, domainId: domainId ?? null } as ResolvedRouteDomainAlias; } const domain = await db('router_domains').first('id').where({ alias: domainAlias }); @@ -20,5 +29,5 @@ export async function resolveDomainAlias; } diff --git a/registry/server/apps/interfaces/index.ts b/registry/server/apps/interfaces/index.ts index caff747fe..aaaf1b686 100644 --- a/registry/server/apps/interfaces/index.ts +++ b/registry/server/apps/interfaces/index.ts @@ -22,6 +22,7 @@ export interface App { discoveryMetadata?: string | null; // JSON({ [propName: string]: any }) adminNotes?: string | null; enforceDomain?: number | null; + domainAlias?: string | null; l10nManifest?: string | null; namespace?: string | null; } @@ -83,17 +84,30 @@ const commonApp = { discoveryMetadata: Joi.object().default({}), adminNotes: Joi.string().trim(), enforceDomain: Joi.number().default(null), + domainAlias: Joi.string() + .lowercase() + .pattern(/^[a-z0-9-]+$/) + .max(64) + .trim(), l10nManifest: Joi.string().max(255), versionId: Joi.string().strip(), namespace: Joi.string().default(null), }; +const validateDomainReference = (value: App, helpers: JoiDefault.CustomHelpers) => { + if (value.enforceDomain != null && value.domainAlias != null) { + return helpers.error('object.oxor', { peers: ['enforceDomain', 'domainAlias'] }); + } + + return value; +}; + export const partialAppSchema = Joi.object({ ...commonApp, name: appNameSchema.forbidden(), -}); +}).custom(validateDomainReference); export const appSchema = Joi.object({ ...commonApp, name: appNameSchema.required(), -}); +}).custom(validateDomainReference); diff --git a/registry/server/apps/services/resolveDomainAlias.ts b/registry/server/apps/services/resolveDomainAlias.ts new file mode 100644 index 000000000..6d4bab8d4 --- /dev/null +++ b/registry/server/apps/services/resolveDomainAlias.ts @@ -0,0 +1,28 @@ +import db from '../../db'; +import { getJoiErr } from '../../util/helpers'; + +type AppWithDomainAlias = { + enforceDomain?: number | null; + domainAlias?: string | null; +}; + +type ResolvedAppDomainAlias = Omit; + +export async function resolveDomainAlias(app: T): Promise> { + const { domainAlias, enforceDomain, ...rest } = app; + + if (!domainAlias) { + if (enforceDomain === undefined) { + return rest as ResolvedAppDomainAlias; + } + + return { ...rest, enforceDomain } as ResolvedAppDomainAlias; + } + + 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, enforceDomain: domain.id } as ResolvedAppDomainAlias; +} diff --git a/registry/server/common/services/entries/ApplicationEntry.ts b/registry/server/common/services/entries/ApplicationEntry.ts index c12576961..31e00ce77 100644 --- a/registry/server/common/services/entries/ApplicationEntry.ts +++ b/registry/server/common/services/entries/ApplicationEntry.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { User } from '../../../../typings/User'; import { App, appSchema, partialAppSchema } from '../../../apps/interfaces'; +import { resolveDomainAlias } from '../../../apps/services/resolveDomainAlias'; import { VersionedKnex } from '../../../db'; import { Tables } from '../../../db/structure'; import { EntityTypes } from '../../../versioning/interfaces'; @@ -39,10 +40,11 @@ export class ApplicationEntry implements Entry { throw new ValidationFqrnError('Patch does not contain any items to update'); } - const appManifest = await this.getManifest(partialAppDTO.assetsDiscoveryUrl); + const resolvedAppDTO = await resolveDomainAlias(partialAppDTO); + const appManifest = await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl); const appEntity = { - ...partialAppDTO, + ...resolvedAppDTO, ...appManifest, }; @@ -66,10 +68,11 @@ export class ApplicationEntry implements Entry { } public async create(appDTO: App, { user }: CommonOptions) { - const appManifest = await this.getManifest(appDTO.assetsDiscoveryUrl); + const resolvedAppDTO = await resolveDomainAlias(appDTO); + const appManifest = await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl); const appEntity = { - ...appDTO, + ...resolvedAppDTO, ...appManifest, }; @@ -84,11 +87,12 @@ export class ApplicationEntry implements Entry { public async upsert(params: unknown, { user, trxProvider, fetchManifest = true }: UpsertOptions): Promise { const appDto = await appSchema.validateAsync(params, { noDefaults: false, externals: true }); + const resolvedAppDTO = await resolveDomainAlias(appDto); - const appManifest = fetchManifest ? await this.getManifest(appDto.assetsDiscoveryUrl) : {}; + const appManifest = fetchManifest ? await this.getManifest(resolvedAppDTO.assetsDiscoveryUrl) : {}; const appEntity = { - ...appDto, + ...resolvedAppDTO, ...appManifest, }; diff --git a/registry/tests/apps.spec.ts b/registry/tests/apps.spec.ts index 04a181091..bfe08a98a 100644 --- a/registry/tests/apps.spec.ts +++ b/registry/tests/apps.spec.ts @@ -217,6 +217,64 @@ describe(`Tests ${example.url}`, () => { } }); + it('should create record with domainAlias', async () => { + let domainId; + const templateName = 'templateName'; + + try { + await req + .post('/api/v1/template/') + .send({ name: templateName, content: 'foo bar' }) + .expect(200); + + const responseRouterDomains = await req + .post('/api/v1/router_domains/') + .send({ domainName: 'foo.com', template500: templateName, alias: 'app-domain' }) + .expect(200); + domainId = responseRouterDomains.body.id; + + const response = await req + .post(example.url) + .send({ ...example.correct, domainAlias: 'app-domain' }) + .expect(200); + + expect(response.body).to.deep.equal({ + ...example.correct, + enforceDomain: domainId, + }); + } finally { + await req.delete(example.url + example.encodedName); + domainId && (await req.delete('/api/v1/router_domains/' + domainId)); + await req.delete('/api/v1/template/' + templateName); + } + }); + + it('should not create record with non-existing domainAlias', async () => { + try { + await req + .post(example.url) + .send({ ...example.correct, domainAlias: 'non-existing' }) + .expect(422); + + await req.get(example.url + example.encodedName).expect(404); + } finally { + await req.delete(example.url + example.encodedName); + } + }); + + it('should not create record with both enforceDomain and domainAlias', async () => { + try { + await req + .post(example.url) + .send({ ...example.correct, enforceDomain: 1, domainAlias: 'some-alias' }) + .expect(422); + + await req.get(example.url + example.encodedName).expect(404); + } finally { + await req.delete(example.url + example.encodedName); + } + }); + it('should not create record with non-existed enforceDomain', async () => { try { await req @@ -859,6 +917,113 @@ describe(`Tests ${example.url}`, () => { } }); + it('should successfully update record with domainAlias', async () => { + let domainId; + const templateName = 'templateName'; + + try { + await req + .post('/api/v1/template/') + .send({ name: templateName, content: 'foo bar' }) + .expect(200); + + const responseRouterDomains = await req + .post('/api/v1/router_domains/') + .send({ domainName: 'foo.com', template500: templateName, alias: 'app-domain-update' }) + .expect(200); + domainId = responseRouterDomains.body.id; + + await req.post(example.url).send(example.correct).expect(200); + + const response = await req + .put(example.url + example.encodedName) + .send({ + ..._.omit(example.updated, 'name'), + domainAlias: 'app-domain-update', + }) + .expect(200); + + expect(response.body).to.deep.equal({ + ...example.updated, + enforceDomain: domainId, + }); + } finally { + await req.delete(example.url + example.encodedName); + domainId && (await req.delete('/api/v1/router_domains/' + domainId)); + await req.delete('/api/v1/template/' + templateName); + } + }); + + it('should preserve enforceDomain on unrelated partial updates', async () => { + let domainId; + const templateName = 'templateName'; + + try { + await req + .post('/api/v1/template/') + .send({ name: templateName, content: 'foo bar' }) + .expect(200); + + const responseRouterDomains = await req + .post('/api/v1/router_domains/') + .send({ domainName: 'foo.com', template500: templateName, alias: 'partial-update-domain' }) + .expect(200); + domainId = responseRouterDomains.body.id; + + await req + .post(example.url) + .send({ ...example.correct, domainAlias: 'partial-update-domain' }) + .expect(200); + + const response = await req + .put(example.url + example.encodedName) + .send({ adminNotes: 'Updated notes only' }) + .expect(200); + + expect(response.body).to.deep.include({ + adminNotes: 'Updated notes only', + enforceDomain: domainId, + }); + } finally { + await req.delete(example.url + example.encodedName); + domainId && (await req.delete('/api/v1/router_domains/' + domainId)); + await req.delete('/api/v1/template/' + templateName); + } + }); + + it('should not update record with non-existing domainAlias', async () => { + try { + await req.post(example.url).send(example.correct).expect(200); + + await req + .put(example.url + example.encodedName) + .send({ + ..._.omit(example.updated, 'name'), + domainAlias: 'non-existing', + }) + .expect(422); + } finally { + await req.delete(example.url + example.encodedName); + } + }); + + it('should not update record with both enforceDomain and domainAlias', async () => { + try { + await req.post(example.url).send(example.correct).expect(200); + + await req + .put(example.url + example.encodedName) + .send({ + ..._.omit(example.updated, 'name'), + enforceDomain: 1, + domainAlias: 'some-alias', + }) + .expect(422); + } finally { + await req.delete(example.url + example.encodedName); + } + }); + it('should be possible to remove\reset fields valued during update', async () => { let domainId; const templateName = 'templateName'; diff --git a/registry/tests/config.spec.ts b/registry/tests/config.spec.ts index 6e0216b01..5fc218f4e 100644 --- a/registry/tests/config.spec.ts +++ b/registry/tests/config.spec.ts @@ -1104,6 +1104,60 @@ describe('Tests /api/v1/config', () => { await req.delete('/api/v1/template/' + example.templates.name); } }); + it('should upsert app 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-app-domain' }) + .expect(200); + domainId = domainResponse.body.id; + + await req + .put('/api/v1/config') + .send({ + apps: [ + { + ...app, + name: 'app-domain-alias', + domainAlias: 'config-app-domain', + }, + ], + }) + .expect(204); + + const { body: config } = await req + .get('/api/v1/config') + .query({ domainName: example.routerDomains.domainName }) + .expect(200); + + expect(config.apps['app-domain-alias']).to.deep.include({ + enforceDomain: example.routerDomains.domainName, + }); + } finally { + await req.delete('/api/v1/app/app-domain-alias'); + domainId && (await req.delete(`/api/v1/router_domains/${domainId}`)); + await req.delete('/api/v1/template/' + example.templates.name); + } + }); + it('should not upsert app with non-existing domainAlias', async () => { + await req + .put('/api/v1/config') + .send({ + apps: [ + { + ...app, + name: 'app-domain-alias-fail', + domainAlias: 'non-existing', + }, + ], + }) + .expect(422); + + const { body: config } = await req.get('/api/v1/config').expect(200); + expect(config.apps['app-domain-alias-fail']).to.be.undefined; + }); it('should not upsert route with non-existing domainAlias', async () => { await req .put('/api/v1/config')