Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 6 additions & 1 deletion packages/oc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ export type {
OcResponse,
UploadedFile
} from './registry/domain/http-server/types';
export type { Plugin, PluginContext } from './types';
export type {
CorsConfig,
CorsOptions,
Plugin,
PluginContext
} from './types';
13 changes: 11 additions & 2 deletions packages/oc/src/registry/domain/options-sanitiser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import zlib from 'node:zlib';
import { compileSync } from 'oc-client-browser';
import settings from '../../resources/settings';
import type { Config } from '../../types';
import type { Config, CorsOptions } from '../../types';
import deprecate from '../../utils/deprecate';
import { normaliseCorsConfig } from '../middleware/cors';
import * as auth from './authentication';
import createExpressAdapter from './http-server/express-adapter';
import type { HttpServerAdapterFactory } from './http-server/types';
Expand All @@ -20,7 +21,7 @@ export interface RegistryOptions<
> extends Partial<
Omit<
Config<T, TServerAdapter>,
'beforePublish' | 'dataProvider' | 'discovery' | 'plugins'
'beforePublish' | 'cors' | 'dataProvider' | 'discovery' | 'plugins'
>
> {
/**
Expand Down Expand Up @@ -50,6 +51,12 @@ export interface RegistryOptions<
robots?: boolean;
}
| boolean;
/**
* CORS response headers sent by the registry.
*
* @default Existing registry CORS headers
*/
cors?: CorsOptions;
/**
* Public base URL where the registry will be accessible by consumers.
* It **must** already include the chosen {@link Config.prefix} and end with a trailing slash.
Expand Down Expand Up @@ -109,6 +116,8 @@ export default function optionsSanitiser<
enabled: options.dataProvider?.enabled !== false
};

options.cors = normaliseCorsConfig(options.cors);

if (typeof options.discovery === 'boolean') {
deprecate({
id: 'registry-config-discovery-boolean',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import strings from '../../../resources';
import type { Config } from '../../../types';
import type { Config, CorsOptions } from '../../../types';
import { validateCorsConfig } from '../../middleware/cors';
import * as auth from '../authentication';
import getMetadataAdapterOptions from '../metadata-adapter-options';

type ValidationResult = { isValid: true } | { isValid: false; message: string };
type RegistryConfiguration = Partial<
Omit<Config, 'dataProvider' | 'discovery' | 'cors'>
> & { cors?: CorsOptions };

export default function registryConfiguration(
conf: Partial<Omit<Config, 'dataProvider' | 'discovery'>>
conf: RegistryConfiguration
): ValidationResult {
const returnError = (message: string): ValidationResult => {
return {
Expand Down Expand Up @@ -35,6 +39,11 @@ export default function registryConfiguration(
}
}

const corsError = validateCorsConfig(conf.cors);
if (corsError) {
return returnError(corsError);
}

const publishAuth = conf.publishAuth;

if (publishAuth) {
Expand Down
93 changes: 86 additions & 7 deletions packages/oc/src/registry/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,93 @@
import strings from '../../resources';
import type { CorsConfig, CorsOptions } from '../../types';
import type { OcHandler } from '../domain/http-server/types';

export const DEFAULT_CORS_CONFIG: CorsConfig = {
origin: '*',
credentials: true,
allowedHeaders: 'Origin, X-Requested-With, Content-Type, Accept, traceparent',
methods: 'GET, OPTIONS, PUT, POST'
};

const asHeaderValue = (
value: string | string[] | undefined,
fallback: string
): string => (Array.isArray(value) ? value.join(', ') : (value ?? fallback));

export const normaliseCorsConfig = (
options?: CorsOptions | null
): CorsConfig => ({
origin: options?.origin ?? DEFAULT_CORS_CONFIG.origin,
credentials: options?.credentials ?? DEFAULT_CORS_CONFIG.credentials,
allowedHeaders: asHeaderValue(
options?.allowedHeaders,
DEFAULT_CORS_CONFIG.allowedHeaders
),
methods: asHeaderValue(options?.methods, DEFAULT_CORS_CONFIG.methods)
});

const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');

export const validateCorsConfig = (options: unknown): string | undefined => {
if (typeof options === 'undefined') {
return undefined;
}

if (
options === null ||
typeof options !== 'object' ||
Array.isArray(options)
) {
return strings.errors.registry.CONFIGURATION_CORS_MUST_BE_OBJECT;
}

const config = options as CorsOptions;

if (
typeof config.origin !== 'undefined' &&
(typeof config.origin !== 'string' || config.origin.length === 0)
) {
return strings.errors.registry.CONFIGURATION_CORS_ORIGIN_MUST_BE_STRING;
}

if (
typeof config.credentials !== 'undefined' &&
typeof config.credentials !== 'boolean'
) {
return strings.errors.registry
.CONFIGURATION_CORS_CREDENTIALS_MUST_BE_BOOLEAN;
}

if (
typeof config.allowedHeaders !== 'undefined' &&
typeof config.allowedHeaders !== 'string' &&
!isStringArray(config.allowedHeaders)
) {
return strings.errors.registry
.CONFIGURATION_CORS_ALLOWED_HEADERS_MUST_BE_STRING_ARRAY;
}

if (
typeof config.methods !== 'undefined' &&
typeof config.methods !== 'string' &&
!isStringArray(config.methods)
) {
return strings.errors.registry
.CONFIGURATION_CORS_METHODS_MUST_BE_STRING_ARRAY;
}

return undefined;
};

const cors: OcHandler = (_req, res) => {
const options = normaliseCorsConfig(res.conf?.cors);

res.removeHeader('X-Powered-By');
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Access-Control-Allow-Origin', '*');
res.set(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, traceparent'
);
res.set('Access-Control-Allow-Methods', 'GET, OPTIONS, PUT, POST');
res.set('Access-Control-Allow-Credentials', String(options.credentials));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 43295cb: Access-Control-Allow-Credentials is now emitted only when cors.credentials is true; the custom HTTP adapter test verifies the header is absent when disabled.

res.set('Access-Control-Allow-Origin', options.origin);
res.set('Access-Control-Allow-Headers', options.allowedHeaders);
res.set('Access-Control-Allow-Methods', options.methods);
};

export default cors;
10 changes: 10 additions & 0 deletions packages/oc/src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export default {
'context.setCookie parameters are not valid',
CONFIGURATION_DEPENDENCIES_MUST_BE_ARRAY:
'Registry configuration is not valid: dependencies must be an array',
CONFIGURATION_CORS_MUST_BE_OBJECT:
'Registry configuration is not valid: cors must be an object',
CONFIGURATION_CORS_ORIGIN_MUST_BE_STRING:
'Registry configuration is not valid: cors.origin must be a non-empty string',
CONFIGURATION_CORS_CREDENTIALS_MUST_BE_BOOLEAN:
'Registry configuration is not valid: cors.credentials must be a boolean',
CONFIGURATION_CORS_ALLOWED_HEADERS_MUST_BE_STRING_ARRAY:
'Registry configuration is not valid: cors.allowedHeaders must be a string or an array of strings',
CONFIGURATION_CORS_METHODS_MUST_BE_STRING_ARRAY:
'Registry configuration is not valid: cors.methods must be a string or an array of strings',
CONFIGURATION_EMPTY: 'Registry configuration is empty',
CONFIGURATION_METADATA_NOT_VALID: (adapterType: string): string =>
`Registry configuration is not valid: ${adapterType} is not a valid metadata adapter`,
Expand Down
20 changes: 20 additions & 0 deletions packages/oc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ export type { ComponentRow, MetadataStore } from 'oc-metadata-adapters-utils';

type Middleware = (req: Request, res: Response, next: NextFunction) => void;

export interface CorsConfig {
origin: string;
credentials: boolean;
allowedHeaders: string;
methods: string;
}

export interface CorsOptions {
origin?: string;
credentials?: boolean;
allowedHeaders?: string | string[];
methods?: string | string[];
}

export interface Author {
email?: string;
name?: string;
Expand Down Expand Up @@ -213,6 +227,12 @@ export interface Config<
* @example "https://components.mycompany.com/"
*/
baseUrl: string;
/**
* CORS response headers sent by the registry.
*
* @default Existing registry CORS headers
*/
cors?: CorsConfig;
/**
* Pre-compiled version of the `oc-client` library generated automatically
* at runtime when `compileClient` is enabled (default).
Expand Down
13 changes: 13 additions & 0 deletions packages/oc/test/types/registry-cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { RegistryOptions } from '../../src/registry';

const options: RegistryOptions = {
baseUrl: 'https://components.example.com/',
cors: {
origin: 'https://app.example.com',
credentials: false,
allowedHeaders: ['Content-Type', 'X-Request-Id'],
methods: ['GET', 'OPTIONS']
}
};

options.cors?.origin;
28 changes: 28 additions & 0 deletions packages/oc/test/unit/registry-domain-options-sanitiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ describe('registry : domain : options-sanitiser', () => {
hotReloading: false,
verbosity: 0,
customHeadersToSkipOnWeakVersion: [],
cors: {
origin: '*',
credentials: true,
allowedHeaders:
'Origin, X-Requested-With, Content-Type, Accept, traceparent',
methods: 'GET, OPTIONS, PUT, POST'
},
timeout: 120000,
dataProvider: { enabled: true }
};
Expand Down Expand Up @@ -111,6 +118,27 @@ describe('registry : domain : options-sanitiser', () => {
});
});

describe('cors configuration', () => {
it('should normalize partial custom values', () => {
const options = sanitise({
baseUrl: 'http://my-registry.com',
cors: {
origin: 'https://app.example.com',
credentials: false,
allowedHeaders: ['Content-Type', 'X-Request-Id'],
methods: ['GET', 'OPTIONS']
}
});

expect(options.cors).to.eql({
origin: 'https://app.example.com',
credentials: false,
allowedHeaders: 'Content-Type, X-Request-Id',
methods: 'GET, OPTIONS'
});
});
});

describe('fallbackRegistryUrl', () => {
describe("when fallbackRegistryUrl doesn't contain / at the end of url", () => {
const options = {
Expand Down
63 changes: 63 additions & 0 deletions packages/oc/test/unit/registry-domain-validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,69 @@ describe('registry : domain : validator', () => {
});
});

describe('cors', () => {
it('should accept partial CORS configuration', () => {
expect(
validate({ cors: { origin: 'https://app.example.com' }, s3: baseS3Conf })
.isValid
).to.be.true;
});

it('should reject a non-object CORS configuration', () => {
const result = validate({ cors: 'all', s3: baseS3Conf });

expect(result.isValid).to.be.false;
expect(result.message).to.equal(
'Registry configuration is not valid: cors must be an object'
);
});

it('should reject an invalid credentials value', () => {
const result = validate({
cors: { credentials: 'true' },
s3: baseS3Conf
});

expect(result.isValid).to.be.false;
expect(result.message).to.equal(
'Registry configuration is not valid: cors.credentials must be a boolean'
);
});

it('should reject an empty origin', () => {
const result = validate({ cors: { origin: '' }, s3: baseS3Conf });

expect(result.isValid).to.be.false;
expect(result.message).to.equal(
'Registry configuration is not valid: cors.origin must be a non-empty string'
);
});

it('should reject non-string allowed headers', () => {
const result = validate({
cors: { allowedHeaders: ['Content-Type', 42] },
s3: baseS3Conf
});

expect(result.isValid).to.be.false;
expect(result.message).to.equal(
'Registry configuration is not valid: cors.allowedHeaders must be a string or an array of strings'
);
});

it('should reject non-string methods', () => {
const result = validate({
cors: { methods: ['GET', false] },
s3: baseS3Conf
});

expect(result.isValid).to.be.false;
expect(result.message).to.equal(
'Registry configuration is not valid: cors.methods must be a string or an array of strings'
);
});
});

describe('s3', () => {
describe('when local=true', () => {
const conf = { local: true };
Expand Down
Loading
Loading