Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export interface BootConfig {
|
||||
/**
|
||||
* Also declared in SystemConfig. If absent, SystemConfig value is used.
|
||||
*/
|
||||
heartbeatInterval?: number | null;
|
||||
/**
|
||||
* Also declared in SystemConfig. If absent, SystemConfig value is used.
|
||||
*/
|
||||
bootRetryInterval?: number | null;
|
||||
status: string;
|
||||
statusInfo?: object | null;
|
||||
/**
|
||||
* Also declared in SystemConfig. If absent, SystemConfig value is used.
|
||||
*/
|
||||
getBaseReportOnPending?: boolean | null;
|
||||
/**
|
||||
* Ids of variable attributes to be sent in SetVariablesRequest on pending boot
|
||||
*/
|
||||
pendingBootSetVariableIds?: number[] | null;
|
||||
/**
|
||||
* Also declared in SystemConfig. If absent, SystemConfig value is used.
|
||||
*/
|
||||
bootWithRejectedVariables?: boolean | null;
|
||||
/**
|
||||
* Specifically for OCPP 1.6 which plays similar role to pendingBootSetVariableIds
|
||||
*/
|
||||
changeConfigurationsOnPending?: boolean | null;
|
||||
/**
|
||||
* Specifically for OCPP 1.6 which plays similar role to getBaseReportOnPending
|
||||
*/
|
||||
getConfigurationsOnPending?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache boot status is used to keep track of the overall boot process for Rejected or Pending.
|
||||
* When Accepting a boot, blacklist needs to be cleared if and only if there was a previously
|
||||
* Rejected or Pending boot. When starting to configure charger, i.e. sending GetBaseReport or
|
||||
* SetVariables, this should only be done if configuring is not still ongoing from a previous
|
||||
* BootNotificationRequest. Cache boot status mediates this behavior.
|
||||
*/
|
||||
export const BOOT_STATUS = 'boot_status';
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IFileStorage } from '@interfaces/files/fileStorage.js';
|
||||
import type { SystemConfig } from './types.js';
|
||||
|
||||
export interface ConfigStore extends IFileStorage {
|
||||
fetchConfig(): Promise<SystemConfig | null>;
|
||||
saveConfig(config: SystemConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export class ConfigStoreFactory {
|
||||
private static instance: ConfigStore | null = null;
|
||||
|
||||
static setConfigStore(configStorage: ConfigStore): ConfigStore {
|
||||
if (this.instance === null) {
|
||||
this.instance = configStorage;
|
||||
} else {
|
||||
console.warn('ConfigStore has already been initialized.');
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
static getInstance(): ConfigStore {
|
||||
if (this.instance === null) {
|
||||
throw new Error(
|
||||
'ConfigStore has not been initialized. Call ConfigStoreFactory.setConfigStore() first.',
|
||||
);
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { z } from 'zod';
|
||||
import { BOOTSTRAP_CONFIG_ENV_VAR_PREFIX } from './defineConfig.js';
|
||||
|
||||
// Bootstrap schema contains what's needed to start the application
|
||||
export const bootstrapConfigSchema = z.object({
|
||||
configFileName: z.string().default('config.json'),
|
||||
configDir: z.string().optional(),
|
||||
|
||||
// Database configuration
|
||||
database: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().int().positive().default(5432),
|
||||
database: z.string().default('citrine'),
|
||||
dialect: z.string().default('postgres'),
|
||||
username: z.string().default('citrine'),
|
||||
password: z.string().default('citrine'),
|
||||
pool: z
|
||||
.object({
|
||||
max: z.number().int().positive().optional(),
|
||||
min: z.number().int().nonnegative().optional(),
|
||||
acquire: z.number().int().positive().optional(),
|
||||
idle: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sync: z.boolean().default(false),
|
||||
alter: z.boolean().default(false),
|
||||
force: z.boolean().default(false),
|
||||
maxRetries: z.number().int().positive().default(3),
|
||||
retryDelay: z.number().int().positive().default(1000),
|
||||
ssl: z
|
||||
.object({
|
||||
require: z.boolean().optional(),
|
||||
rejectUnauthorized: z.boolean().optional(),
|
||||
ca: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
|
||||
// File access configuration
|
||||
fileAccess: z
|
||||
.object({
|
||||
type: z.enum(['local', 's3', 'gcp']),
|
||||
local: z
|
||||
.object({
|
||||
defaultFilePath: z.string().default('data'),
|
||||
})
|
||||
.optional(),
|
||||
s3: z
|
||||
.object({
|
||||
region: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
defaultBucketName: z.string().default('citrineos-s3-bucket'),
|
||||
s3ForcePathStyle: z.boolean().default(true),
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
gcp: z
|
||||
.object({
|
||||
projectId: z.string(),
|
||||
credentials: z.object().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(obj) => {
|
||||
// Ensure the selected type has corresponding config
|
||||
switch (obj.type) {
|
||||
case 'local':
|
||||
return !!obj.local;
|
||||
case 's3':
|
||||
return !!obj.s3;
|
||||
case 'gcp':
|
||||
return !!obj.gcp;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Configuration for the selected file access type must be provided',
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export type BootstrapConfig = z.infer<typeof bootstrapConfigSchema>;
|
||||
|
||||
/**
|
||||
* Helper function to load environment variables based on prefix
|
||||
*/
|
||||
function getEnvVarValue(key: string): string | undefined {
|
||||
const envKey = `${BOOTSTRAP_CONFIG_ENV_VAR_PREFIX}${key}`.toUpperCase();
|
||||
return process.env[envKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a potentially JSON-formatted environment variable
|
||||
*/
|
||||
function parseEnvValue(value: string): any {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bootstrap configuration from environment variables
|
||||
*/
|
||||
export function loadBootstrapConfig(): BootstrapConfig {
|
||||
const config: Record<string, any> = {
|
||||
configFileName: getEnvVarValue('config_filename') || 'config.json',
|
||||
configDir: getEnvVarValue('config_dir'),
|
||||
|
||||
// Database configuration
|
||||
database: {
|
||||
host: getEnvVarValue('database_host'),
|
||||
port: getEnvVarValue('database_port') && parseInt(getEnvVarValue('database_port')!, 10),
|
||||
database: getEnvVarValue('database_name'),
|
||||
dialect: getEnvVarValue('database_dialect'),
|
||||
username: getEnvVarValue('database_username'),
|
||||
password: getEnvVarValue('database_password'),
|
||||
sync: getEnvVarValue('database_sync') && parseEnvValue(getEnvVarValue('database_sync')!),
|
||||
alter: getEnvVarValue('database_alter') && parseEnvValue(getEnvVarValue('database_alter')!),
|
||||
force: getEnvVarValue('database_force') && parseEnvValue(getEnvVarValue('database_force')!),
|
||||
maxRetries:
|
||||
getEnvVarValue('database_max_retries') &&
|
||||
parseInt(getEnvVarValue('database_max_retries')!, 10),
|
||||
retryDelay:
|
||||
getEnvVarValue('database_retry_delay') &&
|
||||
parseInt(getEnvVarValue('database_retry_delay')!, 10),
|
||||
},
|
||||
|
||||
fileAccess: {
|
||||
type: getEnvVarValue('file_access_type') || 'local',
|
||||
},
|
||||
};
|
||||
const pool = {
|
||||
max: getEnvVarValue('database_pool_max') && parseInt(getEnvVarValue('database_pool_max')!, 10),
|
||||
min: getEnvVarValue('database_pool_min') && parseInt(getEnvVarValue('database_pool_min')!, 10),
|
||||
acquire:
|
||||
getEnvVarValue('database_pool_acquire') &&
|
||||
parseInt(getEnvVarValue('database_pool_acquire')!, 10),
|
||||
idle:
|
||||
getEnvVarValue('database_pool_idle') && parseInt(getEnvVarValue('database_pool_idle')!, 10),
|
||||
};
|
||||
if (Object.keys(pool).length > 0) {
|
||||
config.database.pool = pool;
|
||||
}
|
||||
const sslRequire = getEnvVarValue('database_ssl_require');
|
||||
if (sslRequire !== undefined) {
|
||||
config.database.ssl = {
|
||||
require: parseEnvValue(sslRequire),
|
||||
rejectUnauthorized:
|
||||
getEnvVarValue('database_ssl_reject_unauthorized') !== undefined
|
||||
? parseEnvValue(getEnvVarValue('database_ssl_reject_unauthorized')!)
|
||||
: undefined,
|
||||
ca: getEnvVarValue('database_ssl_ca'),
|
||||
};
|
||||
}
|
||||
|
||||
// File access configuration
|
||||
switch (config.fileAccess.type) {
|
||||
case 'local':
|
||||
config.fileAccess.local = {
|
||||
defaultFilePath: getEnvVarValue('file_access_local_default_file_path'),
|
||||
};
|
||||
break;
|
||||
case 's3':
|
||||
config.fileAccess.s3 = {
|
||||
region: getEnvVarValue('file_access_s3_region'),
|
||||
endpoint: getEnvVarValue('file_access_s3_endpoint'),
|
||||
defaultBucketName: getEnvVarValue('file_access_s3_default_bucket_name'),
|
||||
s3ForcePathStyle:
|
||||
getEnvVarValue('file_access_s3_force_path_style') &&
|
||||
parseEnvValue(getEnvVarValue('file_access_s3_force_path_style')!),
|
||||
accessKeyId: getEnvVarValue('file_access_s3_access_key_id'),
|
||||
secretAccessKey: getEnvVarValue('file_access_s3_secret_access_key'),
|
||||
};
|
||||
break;
|
||||
case 'gcp':
|
||||
config.fileAccess.gcp = {
|
||||
projectId: getEnvVarValue('file_access_gcp_project_id'),
|
||||
credentials: getEnvVarValue('file_access_gcp_credentials'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
return bootstrapConfigSchema.parse(config);
|
||||
} catch (error) {
|
||||
console.error('Bootstrap configuration validation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { SystemConfig, SystemConfigInput } from './types.js';
|
||||
import { systemConfigSchema } from './types.js';
|
||||
|
||||
const args = typeof process !== 'undefined' && process.argv ? process.argv.slice(2) : [];
|
||||
|
||||
let dynamicPrefix = 'citrineos_';
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--env-prefix=')) {
|
||||
dynamicPrefix = arg.split('=')[1].toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const CITRINE_ENV_VAR_PREFIX = dynamicPrefix;
|
||||
export const BOOTSTRAP_CONFIG_ENV_VAR_PREFIX = `bootstrap_${CITRINE_ENV_VAR_PREFIX}`;
|
||||
|
||||
/**
|
||||
* Finds a case-insensitive match for a key in an object.
|
||||
* @param obj The object to search.
|
||||
* @param targetKey The target key.
|
||||
* @returns The matching key or undefined.
|
||||
*/
|
||||
function findCaseInsensitiveMatch<T>(
|
||||
obj: Record<string, T>,
|
||||
targetKey: string,
|
||||
): string | undefined {
|
||||
const lowerTargetKey = targetKey.toLowerCase();
|
||||
return Object.keys(obj).find((key) => key.toLowerCase() === lowerTargetKey);
|
||||
}
|
||||
|
||||
const getZodSchemaKeyMap = (schema: z.ZodTypeAny): Record<string, any> => {
|
||||
if (schema instanceof z.ZodNullable || schema instanceof z.ZodOptional) {
|
||||
return getZodSchemaKeyMap((schema as z.ZodNullable<any> | z.ZodOptional<any>).unwrap());
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return getZodSchemaKeyMap(schema.element as z.ZodTypeAny);
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const entries = Object.entries<z.ZodType>(schema.shape);
|
||||
|
||||
return entries.reduce(
|
||||
(acc, [key, value]) => {
|
||||
const nested = getZodSchemaKeyMap(value);
|
||||
|
||||
if (Object.keys(nested).length > 0) {
|
||||
acc[key] = nested;
|
||||
} else {
|
||||
acc[key.toLowerCase()] = key;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges configuration from environment variables into the default configuration. Allows any to keep it as generic as possible.
|
||||
* @param defaultConfig The default configuration.
|
||||
* @param envVars The environment variables.
|
||||
* @returns The merged configuration.
|
||||
*/
|
||||
function mergeConfigFromEnvVars<T extends Record<string, any>>(
|
||||
defaultConfig: T,
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
configKeyMap: Record<string, any>,
|
||||
): T {
|
||||
const config: T = { ...defaultConfig };
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [fullEnvKey, value] of Object.entries(envVars)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
const lowercaseEnvKey = fullEnvKey.toLowerCase();
|
||||
if (lowercaseEnvKey.startsWith(CITRINE_ENV_VAR_PREFIX)) {
|
||||
const envKeyWithoutPrefix = lowercaseEnvKey.substring(CITRINE_ENV_VAR_PREFIX.length);
|
||||
const path = envKeyWithoutPrefix.split('_');
|
||||
|
||||
let currentConfigPart: Record<string, any> = config;
|
||||
let currentConfigKeyMap: Record<string, any> = configKeyMap;
|
||||
let validMapping = true;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const part = path[i];
|
||||
const matchingKey = findCaseInsensitiveMatch(currentConfigKeyMap, part);
|
||||
if (!matchingKey) {
|
||||
errors.push(
|
||||
`Environment variable '${fullEnvKey}' refers to unknown configuration segment '${part}'.`,
|
||||
);
|
||||
validMapping = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentConfigPart[matchingKey] === undefined) {
|
||||
currentConfigPart[matchingKey] = {};
|
||||
} else if (
|
||||
typeof currentConfigPart[matchingKey] !== 'object' ||
|
||||
currentConfigPart[matchingKey] === null
|
||||
) {
|
||||
errors.push(
|
||||
`Environment variable '${fullEnvKey}' refers to configuration segment '${part}', but its current value is not an object.`,
|
||||
);
|
||||
validMapping = false;
|
||||
break;
|
||||
}
|
||||
|
||||
currentConfigPart = currentConfigPart[matchingKey];
|
||||
currentConfigKeyMap = currentConfigKeyMap[matchingKey];
|
||||
}
|
||||
|
||||
if (!validMapping) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const finalPart = path[path.length - 1];
|
||||
const keyToUse = currentConfigKeyMap[finalPart.toLowerCase()] || finalPart;
|
||||
|
||||
try {
|
||||
currentConfigPart[keyToUse] = JSON.parse(value as string);
|
||||
} catch {
|
||||
console.debug(`Mapping '${value}' as string for environment variable '${fullEnvKey}'.`);
|
||||
currentConfigPart[keyToUse] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors.forEach((err) => console.error(err));
|
||||
|
||||
return config as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the system configuration to ensure required properties are set.
|
||||
* @param finalConfig The final system configuration.
|
||||
* @throws Error if required properties are not set.
|
||||
*/
|
||||
function validateFinalConfig(finalConfig: SystemConfigInput): SystemConfig {
|
||||
return systemConfigSchema.parse(finalConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the application configuration by merging input configuration which is defined in a file with environment variables.
|
||||
* Takes environment variables over predefined
|
||||
* @param inputConfig The file defined input configuration.
|
||||
* @returns The final system configuration.
|
||||
* @throws Error if required environment variables are not set or if there are parsing errors.
|
||||
*/
|
||||
export function defineConfig(inputConfig: SystemConfigInput): SystemConfig {
|
||||
const configKeyMap: Record<string, any> = getZodSchemaKeyMap(systemConfigSchema);
|
||||
const appConfig = mergeConfigFromEnvVars<SystemConfigInput>(
|
||||
inputConfig,
|
||||
process.env,
|
||||
configKeyMap,
|
||||
);
|
||||
|
||||
return validateFinalConfig(appConfig);
|
||||
}
|
||||
|
||||
export const DEFAULT_TENANT_ID = 1;
|
||||
670
tools/citrineos-core-main/packages/base/src/config/types.ts
Normal file
670
tools/citrineos-core-main/packages/base/src/config/types.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { RegistrationStatusEnum } from '@interfaces/dto/types/enums.js';
|
||||
import { EventGroup } from '@interfaces/messages/internal-types.js';
|
||||
import { OCPP1_6 } from '@ocpp/model/index.js';
|
||||
import { OCPP_CallAction, OCPPVersion, type OCPPVersionType } from '@ocpp/rpc/message.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CallActionSchema = z.nativeEnum(OCPP_CallAction);
|
||||
|
||||
export const oidcClientConfigSchema = z
|
||||
.object({
|
||||
tokenUrl: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
audience: z.string(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const OCPP_VERSION_LIST: OCPPVersionType[] = [
|
||||
OCPPVersion.OCPP2_1,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
OCPPVersion.OCPP1_6,
|
||||
] as const;
|
||||
|
||||
const signedMeterValuesSigningMethods = ['RSASSA-PKCS1-v1_5', 'ECDSA', 'SECP192R1'] as const;
|
||||
|
||||
// TODO: Refactor other objects out of system config, such as certificatesModuleInputSchema etc.
|
||||
export const websocketServerInputSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8080).optional(),
|
||||
pingInterval: z.number().int().min(1).default(60).optional(),
|
||||
protocols: z.array(z.enum(OCPP_VERSION_LIST)).default(['ocpp2.0.1']).optional(),
|
||||
securityProfile: z.number().int().min(0).max(3).default(0).optional(),
|
||||
allowUnknownChargingStations: z.boolean().default(false).optional(),
|
||||
ignoreAuthenticationHeaders: z.boolean().default(false).optional(), // When true, authorization headers will be ignored and authentication will be bypassed.
|
||||
tlsKeyFilePath: z.string().optional(), // Leaf certificate's private key pem which decrypts the message from client
|
||||
tlsCertificateChainFilePath: z.string().optional(), // Certificate chain pem consist of a leaf followed by sub CAs
|
||||
mtlsCertificateAuthorityKeyFilePath: z.string().optional(), // Sub CA's private key which signs the leaf (e.g.,
|
||||
// charging station certificate and csms certificate)
|
||||
rootCACertificateFilePath: z.string().optional(), // Root CA certificate that overrides default CA certificates
|
||||
// allowed by Mozilla
|
||||
tenantId: z.number().optional(),
|
||||
// Mapping from path segments to tenant IDs.
|
||||
// Example: { "my-tenant": 1 }
|
||||
tenantPathMapping: z.record(z.string(), z.number()).optional(),
|
||||
// When true, tenant can be resolved at connection upgrade time from the request
|
||||
// (query param, path segment, or header). Defaults to false for strict per-server tenant.
|
||||
dynamicTenantResolution: z.boolean().optional().default(false),
|
||||
// Forces a set protocol to communicate on, mostly used for dev purposes
|
||||
forceProtocol: z.enum(OCPP_VERSION_LIST).optional(),
|
||||
});
|
||||
|
||||
export const HUBJECT_DEFAULT_BASEURL = 'https://open.plugncharge-test.hubject.com';
|
||||
export const HUBJECT_DEFAULT_TOKENURL =
|
||||
'https://hubject.stoplight.io/api/v1/projects/cHJqOjk0NTg5/nodes/6bb8b3bc79c2e-authorization-token';
|
||||
export const HUBJECT_DEFAULT_CLIENTID = 'YOUR_CLIENT_ID';
|
||||
export const HUBJECT_DEFAULT_CLIENTSECRET = 'YOUR_CLIENT_SECRET';
|
||||
|
||||
export const systemConfigInputSchema = z.object({
|
||||
env: z.enum(['development', 'production']),
|
||||
centralSystem: z.object({
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
}),
|
||||
modules: z.object({
|
||||
certificates: z
|
||||
.object({
|
||||
endpointPrefix: z.string().default(EventGroup.Certificates).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
})
|
||||
.optional(),
|
||||
configuration: z.object({
|
||||
heartbeatInterval: z.number().int().min(1).default(60).optional(),
|
||||
bootRetryInterval: z.number().int().min(1).default(10).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
ocpp2_0_1: z
|
||||
.object({
|
||||
unknownChargerStatus: z
|
||||
.enum([
|
||||
RegistrationStatusEnum.Accepted,
|
||||
RegistrationStatusEnum.Pending,
|
||||
RegistrationStatusEnum.Rejected,
|
||||
])
|
||||
.default(RegistrationStatusEnum.Accepted)
|
||||
.optional(), // Unknown chargers have no entry in BootConfig table
|
||||
getBaseReportOnPending: z.boolean().default(true).optional(),
|
||||
bootWithRejectedVariables: z.boolean().default(true).optional(),
|
||||
autoAccept: z.boolean().default(true).optional(), // If false, only data endpoint can update boot status to accepted
|
||||
})
|
||||
.optional(),
|
||||
ocpp2_1: z
|
||||
.object({
|
||||
unknownChargerStatus: z
|
||||
.enum([
|
||||
RegistrationStatusEnum.Accepted,
|
||||
RegistrationStatusEnum.Pending,
|
||||
RegistrationStatusEnum.Rejected,
|
||||
])
|
||||
.default(RegistrationStatusEnum.Accepted)
|
||||
.optional(), // Unknown chargers have no entry in BootConfig table
|
||||
getBaseReportOnPending: z.boolean().default(true).optional(),
|
||||
bootWithRejectedVariables: z.boolean().default(true).optional(),
|
||||
autoAccept: z.boolean().default(true).optional(), // If false, only data endpoint can update boot status to accepted
|
||||
})
|
||||
.optional(),
|
||||
ocpp1_6: z
|
||||
.object({
|
||||
unknownChargerStatus: z
|
||||
.enum([
|
||||
OCPP1_6.BootNotificationResponseStatus.Accepted,
|
||||
OCPP1_6.BootNotificationResponseStatus.Pending,
|
||||
OCPP1_6.BootNotificationResponseStatus.Rejected,
|
||||
])
|
||||
.default(OCPP1_6.BootNotificationResponseStatus.Accepted)
|
||||
.optional(), // Unknown chargers have no entry in BootConfig table
|
||||
})
|
||||
.optional(),
|
||||
endpointPrefix: z.string().default(EventGroup.Configuration).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
}),
|
||||
evdriver: z.object({
|
||||
endpointPrefix: z.string().default(EventGroup.EVDriver).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
enableGetChargingProfilesOnStartTransaction: z.boolean().default(true).optional(),
|
||||
}),
|
||||
monitoring: z.object({
|
||||
endpointPrefix: z.string().default(EventGroup.Monitoring).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
}),
|
||||
reporting: z.object({
|
||||
endpointPrefix: z.string().default(EventGroup.Reporting).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
}),
|
||||
smartcharging: z
|
||||
.object({
|
||||
endpointPrefix: z.string().default(EventGroup.SmartCharging).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
})
|
||||
.optional(),
|
||||
tenant: z
|
||||
.object({
|
||||
endpointPrefix: z.string().default(EventGroup.Tenant).optional(),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
ocppRouterBaseUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
transactions: z.object({
|
||||
endpointPrefix: z.string().default(EventGroup.Transactions).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8081).optional(),
|
||||
costUpdatedInterval: z.number().int().min(1).default(60).optional(),
|
||||
sendCostUpdatedOnMeterValue: z.boolean().default(false).optional(),
|
||||
signedMeterValuesConfiguration: z
|
||||
.object({
|
||||
publicKeyFileId: z.string(),
|
||||
signingMethod: z.enum(signedMeterValuesSigningMethods),
|
||||
rejectUnsupportedSignedMeterValues: z.boolean().default(false).optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Base URL for generating receipt URLs when ReceiptByCSMS is true (C21). */
|
||||
receiptBaseUrl: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
util: z.object({
|
||||
cache: z
|
||||
.object({
|
||||
memory: z.boolean().optional(),
|
||||
redis: z
|
||||
.union([
|
||||
z.object({
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(6379).optional(),
|
||||
}),
|
||||
z.object({
|
||||
url: z.url().refine((v) => v.startsWith('redis://') || v.startsWith('rediss://'), {
|
||||
message: 'Redis URL must start with redis:// or rediss://',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => obj.memory || obj.redis, {
|
||||
message: 'A cache implementation must be set',
|
||||
}),
|
||||
messageBroker: z
|
||||
.object({
|
||||
amqp: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
exchange: z.string(),
|
||||
instanceIdentifier: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => obj.amqp, {
|
||||
message: 'A message broker implementation must be set',
|
||||
}),
|
||||
authProvider: z
|
||||
.object({
|
||||
oidc: z
|
||||
.object({
|
||||
jwksUri: z.string(),
|
||||
issuer: z.string(),
|
||||
audience: z.string(),
|
||||
cacheTime: z.number().int().min(1).optional(),
|
||||
rateLimit: z.boolean().default(false).optional(),
|
||||
})
|
||||
.optional(),
|
||||
localByPass: z.boolean().default(false).optional(),
|
||||
})
|
||||
.refine((obj) => obj.oidc || obj.localByPass, {
|
||||
message: 'An auth provider implementation must be set',
|
||||
}),
|
||||
swagger: z
|
||||
.object({
|
||||
path: z.string().default('/docs').optional(),
|
||||
logoPath: z.string(),
|
||||
exposeData: z.boolean().default(true).optional(),
|
||||
exposeMessage: z.boolean().default(true).optional(),
|
||||
})
|
||||
.optional(),
|
||||
networkConnection: z.object({
|
||||
websocketServers: z.array(websocketServerInputSchema.optional()),
|
||||
}),
|
||||
certificateAuthority: z.object({
|
||||
v2gCA: z
|
||||
.object({
|
||||
name: z.enum(['hubject']).default('hubject'),
|
||||
hubject: z
|
||||
.object({
|
||||
baseUrl: z.string().default(HUBJECT_DEFAULT_BASEURL),
|
||||
tokenUrl: z.string().default(HUBJECT_DEFAULT_TOKENURL),
|
||||
clientId: z.string().default(HUBJECT_DEFAULT_CLIENTID),
|
||||
clientSecret: z.string().default(HUBJECT_DEFAULT_CLIENTSECRET),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(obj) => {
|
||||
if (obj.name === 'hubject') {
|
||||
return (
|
||||
obj.hubject &&
|
||||
obj.hubject.baseUrl &&
|
||||
obj.hubject.tokenUrl &&
|
||||
obj.hubject.clientId &&
|
||||
obj.hubject.clientSecret
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Hubject requires baseUrl, tokenUrl, clientId, and clientSecret',
|
||||
},
|
||||
),
|
||||
chargingStationCA: z
|
||||
.object({
|
||||
name: z.enum(['acme']).default('acme'),
|
||||
acme: z
|
||||
.object({
|
||||
env: z.enum(['staging', 'production']).default('staging'),
|
||||
accountKeyFilePath: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => {
|
||||
if (obj.name === 'acme') {
|
||||
return obj.acme;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
logLevel: z.number().min(0).max(6).default(0).optional(),
|
||||
maxCallLengthSeconds: z.number().int().min(1).default(20).optional(),
|
||||
maxCachingSeconds: z.number().int().min(1).default(30).optional(),
|
||||
maxReconnectDelay: z.number().int().min(1).default(30).optional(),
|
||||
shutdownGracePeriodSeconds: z.number().int().min(1).default(30).optional(),
|
||||
ocpiServer: z.object({
|
||||
host: z.string().default('localhost').optional(),
|
||||
port: z.number().int().min(1).default(8085).optional(),
|
||||
}),
|
||||
userPreferences: z.object({
|
||||
telemetryConsent: z.boolean().default(false).optional(),
|
||||
}),
|
||||
rbacRulesFileName: z.string().default('rbac-rules.json').optional(),
|
||||
rbacRulesDir: z.string().optional(),
|
||||
realTimeAuthDefaultTimeoutSeconds: z.number().int().min(1).default(15).optional(),
|
||||
notReadyThresholdSeconds: z.number().int().min(1).default(60).optional(),
|
||||
});
|
||||
|
||||
export type SystemConfigInput = z.infer<typeof systemConfigInputSchema>;
|
||||
|
||||
export const websocketServerSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number().int().min(1),
|
||||
pingInterval: z.number().int().min(1),
|
||||
protocols: z.array(z.enum(OCPP_VERSION_LIST)),
|
||||
securityProfile: z.number().int().min(0).max(3),
|
||||
allowUnknownChargingStations: z.boolean(),
|
||||
ignoreAuthenticationHeaders: z.boolean().default(false).optional(),
|
||||
tlsKeyFilePath: z.string().optional(),
|
||||
tlsCertificateChainFilePath: z.string().optional(),
|
||||
mtlsCertificateAuthorityKeyFilePath: z.string().optional(),
|
||||
rootCACertificateFilePath: z.string().optional(),
|
||||
tenantId: z.number().optional(),
|
||||
tenantPathMapping: z.record(z.string(), z.number()).optional(),
|
||||
// When true, tenant can be resolved at connection upgrade time from the request
|
||||
// (query param, path segment, or header). Defaults to false for strict per-server tenant.
|
||||
dynamicTenantResolution: z.boolean().optional().default(false),
|
||||
forceProtocol: z.enum(OCPP_VERSION_LIST).optional(),
|
||||
})
|
||||
.refine((obj) => {
|
||||
switch (obj.securityProfile) {
|
||||
case 0: // No security
|
||||
case 1: // Basic Auth
|
||||
return true;
|
||||
case 2: // Basic Auth + TLS
|
||||
return obj.tlsKeyFilePath && obj.tlsCertificateChainFilePath;
|
||||
case 3: // mTLS
|
||||
return (
|
||||
obj.tlsCertificateChainFilePath &&
|
||||
obj.tlsKeyFilePath &&
|
||||
obj.mtlsCertificateAuthorityKeyFilePath
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.refine((obj) => {
|
||||
if ((obj.tenantId !== undefined) === obj.dynamicTenantResolution) {
|
||||
return false; // Cannot have both or neither tenantId and dynamicTenantResolution
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}, 'Invalid websocket server configuration: tenantId and dynamicTenantResolution are mutually exclusive and one must be set');
|
||||
|
||||
export const systemConfigSchema = z
|
||||
.object({
|
||||
env: z.enum(['development', 'production']),
|
||||
centralSystem: z.object({
|
||||
host: z.string(),
|
||||
port: z.number().int().min(1),
|
||||
}),
|
||||
modules: z.object({
|
||||
certificates: z
|
||||
.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
})
|
||||
.optional(),
|
||||
evdriver: z.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
enableGetChargingProfilesOnStartTransaction: z.boolean().optional(),
|
||||
}),
|
||||
configuration: z
|
||||
.object({
|
||||
heartbeatInterval: z.number().int().min(1),
|
||||
bootRetryInterval: z.number().int().min(1),
|
||||
ocpp2_0_1: z
|
||||
.object({
|
||||
unknownChargerStatus: z.enum([
|
||||
RegistrationStatusEnum.Accepted,
|
||||
RegistrationStatusEnum.Pending,
|
||||
RegistrationStatusEnum.Rejected,
|
||||
]), // Unknown chargers have no entry in BootConfig table
|
||||
getBaseReportOnPending: z.boolean(),
|
||||
bootWithRejectedVariables: z.boolean(),
|
||||
/**
|
||||
* If false, only data endpoint can update boot status to accepted
|
||||
*/
|
||||
autoAccept: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
ocpp2_1: z
|
||||
.object({
|
||||
unknownChargerStatus: z.enum([
|
||||
RegistrationStatusEnum.Accepted,
|
||||
RegistrationStatusEnum.Pending,
|
||||
RegistrationStatusEnum.Rejected,
|
||||
]), // Unknown chargers have no entry in BootConfig table
|
||||
getBaseReportOnPending: z.boolean(),
|
||||
bootWithRejectedVariables: z.boolean(),
|
||||
/**
|
||||
* If false, only data endpoint can update boot status to accepted
|
||||
*/
|
||||
autoAccept: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
ocpp1_6: z
|
||||
.object({
|
||||
unknownChargerStatus: z.enum([
|
||||
OCPP1_6.BootNotificationResponseStatus.Accepted,
|
||||
OCPP1_6.BootNotificationResponseStatus.Pending,
|
||||
OCPP1_6.BootNotificationResponseStatus.Rejected,
|
||||
]), // Unknown chargers have no entry in BootConfig table
|
||||
})
|
||||
.optional(),
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
})
|
||||
.refine((obj) => obj.ocpp1_6 || obj.ocpp2_0_1 || obj.ocpp2_1, {
|
||||
message: 'A protocol configuration must be set',
|
||||
}), // Configuration module is required
|
||||
monitoring: z.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
}),
|
||||
reporting: z.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
}),
|
||||
smartcharging: z
|
||||
.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
})
|
||||
.optional(),
|
||||
tenant: z.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
ocppRouterBaseUrl: z.string().optional(),
|
||||
}),
|
||||
transactions: z
|
||||
.object({
|
||||
endpointPrefix: z.string(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).optional(),
|
||||
costUpdatedInterval: z.number().int().min(1).optional(),
|
||||
sendCostUpdatedOnMeterValue: z.boolean().optional(),
|
||||
requests: z.array(CallActionSchema),
|
||||
responses: z.array(CallActionSchema),
|
||||
signedMeterValuesConfiguration: z
|
||||
.object({
|
||||
publicKeyFileId: z.string(),
|
||||
signingMethod: z.enum(signedMeterValuesSigningMethods),
|
||||
rejectUnsupportedSignedMeterValues: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Base URL for generating receipt URLs when ReceiptByCSMS is true (C21). */
|
||||
receiptBaseUrl: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(obj) =>
|
||||
!(obj.costUpdatedInterval && obj.sendCostUpdatedOnMeterValue) &&
|
||||
(obj.costUpdatedInterval || obj.sendCostUpdatedOnMeterValue),
|
||||
{
|
||||
message:
|
||||
'Can only update cost based on the interval or in response to a transaction event /meter value' +
|
||||
' update. Not allowed to have both costUpdatedInterval and sendCostUpdatedOnMeterValue configured',
|
||||
},
|
||||
),
|
||||
}),
|
||||
util: z.object({
|
||||
cache: z
|
||||
.object({
|
||||
memory: z.boolean().optional(),
|
||||
redis: z
|
||||
.union([
|
||||
z.object({
|
||||
host: z.string(),
|
||||
port: z.number().int().min(1),
|
||||
}),
|
||||
z.object({
|
||||
url: z.url().refine((v) => v.startsWith('redis://') || v.startsWith('rediss://'), {
|
||||
message: 'Redis URL must start with redis:// or rediss://',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => obj.memory || obj.redis, {
|
||||
message: 'A cache implementation must be set',
|
||||
}),
|
||||
messageBroker: z
|
||||
.object({
|
||||
amqp: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
exchange: z.string(),
|
||||
instanceIdentifier: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => obj.amqp, {
|
||||
message: 'A message broker implementation must be set',
|
||||
}),
|
||||
authProvider: z
|
||||
.object({
|
||||
oidc: z
|
||||
.object({
|
||||
jwksUri: z.string(),
|
||||
issuer: z.string(),
|
||||
audience: z.string(),
|
||||
cacheTime: z.number().int().min(1).optional(),
|
||||
rateLimit: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
localByPass: z.boolean().default(false).optional(),
|
||||
})
|
||||
.refine((obj) => obj.oidc || obj.localByPass, {
|
||||
message: 'An auth provider implementation must be set',
|
||||
}),
|
||||
swagger: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
logoPath: z.string(),
|
||||
exposeData: z.boolean(),
|
||||
exposeMessage: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
networkConnection: z.object({
|
||||
websocketServers: z.array(websocketServerSchema).refine((array) => {
|
||||
const idsSeen = new Set<string>();
|
||||
return array.filter((obj) => {
|
||||
if (idsSeen.has(obj.id)) {
|
||||
return false;
|
||||
} else {
|
||||
idsSeen.add(obj.id);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}),
|
||||
}),
|
||||
certificateAuthority: z.object({
|
||||
v2gCA: z
|
||||
.object({
|
||||
name: z.enum(['hubject']),
|
||||
hubject: z
|
||||
.object({
|
||||
baseUrl: z.string(),
|
||||
tokenUrl: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(obj) => {
|
||||
if (obj.name === 'hubject') {
|
||||
return (
|
||||
obj.hubject &&
|
||||
obj.hubject.baseUrl &&
|
||||
obj.hubject.tokenUrl &&
|
||||
obj.hubject.clientId &&
|
||||
obj.hubject.clientSecret
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Hubject requires baseUrl, tokenUrl, clientId, and clientSecret',
|
||||
},
|
||||
),
|
||||
chargingStationCA: z
|
||||
.object({
|
||||
name: z.enum(['acme']),
|
||||
acme: z
|
||||
.object({
|
||||
env: z.enum(['staging', 'production']),
|
||||
accountKeyFilePath: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => {
|
||||
if (obj.name === 'acme') {
|
||||
return obj.acme;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
logLevel: z.number().min(0).max(6),
|
||||
maxCallLengthSeconds: z.number().int().min(1),
|
||||
maxCachingSeconds: z.number().int().min(1),
|
||||
maxReconnectDelay: z.number().int().min(1).default(30),
|
||||
shutdownGracePeriodSeconds: z.number().int().min(1).default(30),
|
||||
ocpiServer: z.object({
|
||||
host: z.string(),
|
||||
port: z.number().int().min(1),
|
||||
}),
|
||||
userPreferences: z.object({
|
||||
telemetryConsent: z.boolean().optional(),
|
||||
}),
|
||||
rbacRulesFileName: z.string().optional(),
|
||||
rbacRulesDir: z.string().optional(),
|
||||
oidcClient: oidcClientConfigSchema,
|
||||
realTimeAuthDefaultTimeoutSeconds: z.number().int().min(1).default(15),
|
||||
notReadyThresholdSeconds: z.number().int().min(1).default(60),
|
||||
})
|
||||
.refine((obj) => obj.maxCachingSeconds >= obj.maxCallLengthSeconds, {
|
||||
message: 'maxCachingSeconds cannot be less than maxCallLengthSeconds',
|
||||
});
|
||||
|
||||
export const HttpMethodSchema = z.record(
|
||||
z.string(), // HTTP method (GET, POST, etc., or * for all methods)
|
||||
z.array(z.string()), // Array of role names required for this method
|
||||
);
|
||||
|
||||
export const UrlPatternSchema = z.record(
|
||||
z.string(), // URL pattern (/api/users, /api/users/:id, etc.)
|
||||
HttpMethodSchema,
|
||||
);
|
||||
|
||||
export const TenantSchema = z.record(
|
||||
z.string(), // Tenant ID
|
||||
UrlPatternSchema,
|
||||
);
|
||||
|
||||
export const RbacRulesSchema = TenantSchema;
|
||||
|
||||
export type RbacRules = z.infer<typeof RbacRulesSchema>;
|
||||
|
||||
export type WebsocketServerConfig = z.infer<typeof websocketServerSchema>;
|
||||
export type SystemConfig = z.infer<typeof systemConfigSchema>;
|
||||
Reference in New Issue
Block a user