UNPKG

@citrineos/base

Version:

The base module for OCPP v2.0.1 including all interfaces. This module is not intended to be used directly, but rather as a dependency for other modules.

632 lines 27.1 kB
// 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 } 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 = [ OCPPVersion.OCPP2_1, OCPPVersion.OCPP2_0_1, OCPPVersion.OCPP1_6, ]; const signedMeterValuesSigningMethods = ['RSASSA-PKCS1-v1_5', 'ECDSA', 'SECP192R1']; // 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 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(); 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())); 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; //# sourceMappingURL=types.js.map