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
14 changes: 14 additions & 0 deletions docs/multi-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 15 additions & 3 deletions registry/server/appRoutes/routes/RoutesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppRoute & AppRouteSlot>;

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

Expand Down Expand Up @@ -127,7 +139,7 @@ export class RoutesService {
}

private async findExistingOrderPos(
appRoute: Omit<AppRouteDto, 'slots'>,
appRoute: ExistingRouteLookup,
trx: Knex.Transaction,
): Promise<number | undefined | null> {
const existingRoute = await this.db(Tables.Routes)
Expand All @@ -138,7 +150,7 @@ export class RoutesService {
}

private async findExistingRouteId(
appRoute: Omit<AppRouteDto, 'slots' | 'meta'>,
appRoute: ExistingRouteByOrderPosLookup,
trx: Knex.Transaction,
): Promise<number | undefined> {
const existingRoute = await this.db(Tables.Routes)
Expand Down
17 changes: 13 additions & 4 deletions registry/server/appRoutes/services/resolveDomainAlias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@ 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<T extends { domainId?: number | null; domainAlias?: string | null }>(
type AppRouteWithDomainAlias = {
domainId?: number | null;
domainAlias?: string | null;
};

type ResolvedRouteDomainAlias<T extends AppRouteWithDomainAlias> = Omit<T, 'domainAlias' | 'domainId'> & {
domainId: number | null;
};

export async function resolveDomainAlias<T extends AppRouteWithDomainAlias>(
appRoute: T,
): Promise<T> {
): Promise<ResolvedRouteDomainAlias<T>> {
const { domainAlias, domainId, ...rest } = appRoute;

if (!domainAlias) {
return { ...rest, domainId: domainId ?? null } as T;
return { ...rest, domainId: domainId ?? null } as ResolvedRouteDomainAlias<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;
return { ...rest, domainId: domain.id } as ResolvedRouteDomainAlias<T>;
}
18 changes: 16 additions & 2 deletions registry/server/apps/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<App>) => {
if (value.enforceDomain != null && value.domainAlias != null) {
return helpers.error('object.oxor', { peers: ['enforceDomain', 'domainAlias'] });
}

return value;
};

export const partialAppSchema = Joi.object<App>({
...commonApp,
name: appNameSchema.forbidden(),
});
}).custom(validateDomainReference);

export const appSchema = Joi.object<App>({
...commonApp,
name: appNameSchema.required(),
});
}).custom(validateDomainReference);
28 changes: 28 additions & 0 deletions registry/server/apps/services/resolveDomainAlias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import db from '../../db';
import { getJoiErr } from '../../util/helpers';

type AppWithDomainAlias = {
enforceDomain?: number | null;
domainAlias?: string | null;
};

type ResolvedAppDomainAlias<T extends AppWithDomainAlias> = Omit<T, 'domainAlias'>;

export async function resolveDomainAlias<T extends AppWithDomainAlias>(app: T): Promise<ResolvedAppDomainAlias<T>> {
const { domainAlias, enforceDomain, ...rest } = app;

if (!domainAlias) {
if (enforceDomain === undefined) {
return rest as ResolvedAppDomainAlias<T>;
}

return { ...rest, enforceDomain } as ResolvedAppDomainAlias<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, enforceDomain: domain.id } as ResolvedAppDomainAlias<T>;
}
16 changes: 10 additions & 6 deletions registry/server/common/services/entries/ApplicationEntry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
};

Expand All @@ -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,
};

Expand All @@ -84,11 +87,12 @@ export class ApplicationEntry implements Entry {

public async upsert(params: unknown, { user, trxProvider, fetchManifest = true }: UpsertOptions): Promise<App> {
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,
};

Expand Down
165 changes: 165 additions & 0 deletions registry/tests/apps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<html><head></head><body>foo bar</body></html>' })
.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
Expand Down Expand Up @@ -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: '<html><head></head><body>foo bar</body></html>' })
.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: '<html><head></head><body>foo bar</body></html>' })
.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';
Expand Down
Loading
Loading