import * as Joi from 'joi'; import { utc as moment } from 'moment'; import { isPlainObject } from 'lodash'; // @ts-ignore import * as checkdigit from 'checkdigit'; const extendedJoi = Joi.extend( (joi: any) => ({ type: 'string', base: joi.string(), messages: { 'string.idNumber': '{{#label}} should be a valid South African ID number', }, rules: { idNumber: { validate(value: any, helpers: any) { if (/[0-9]+/.test(value) && value.length === 13 && moment(value.slice(0, 6), 'YYMMDD', true).isValid()) { let nCheck = 0; let nDigit = 0; let bEven = false; for (let n = value.length - 1; n >= 0; n -= 1) { const cDigit = value.charAt(n); nDigit = Number.parseInt(cDigit, 10); if (bEven) { nDigit *= 2; if (nDigit > 9) { nDigit -= 9; } } nCheck += nDigit; bEven = !bEven; } if (nCheck % 10 === 0) { return value; } } return helpers.error('string.idNumber'); }, }, imei: { validate(value: any, helpers: any) { const is15Digits = /^[0-9]{15}$/.test(value); const checksumPasses = checkdigit.mod10.isValid(value); if (is15Digits && checksumPasses) { return value; } return helpers.error('string.imei'); }, }, digits: { validate(value: any, helpers: any) { if (/^[0-9]*$/.test(value)) { return value; } return helpers.error('string.digits'); }, }, jsonString: { validate(value: any, helpers: any) { try { JSON.parse(value); } catch { return helpers.error('string.jsonString'); } return value; }, }, rfcEmail: { validate(value: any, helpers: any) { const schema = joi.string().email(); const result = schema.validate(value); if (result.error) { let sanitizedValue = value; const isGmailEmail = /^[a-z0-9\+\.]{5,}(\@|@)g(oogle)?mail\.com$/.test(value); if (isGmailEmail) { sanitizedValue = value.replace('.@', '@').replace('+@', '@'); } const isRfcEmail = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|("\.\+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( sanitizedValue, ); if (isRfcEmail) { return value; } return helpers.error('string.rfcEmail'); } return value; }, }, }, }), (joi: any) => ({ type: 'cellphone', base: joi.string(), messages: { 'cellphone.invalidForCountry': '{{#label}} should be a valid cellphone number for country {{#country}}', }, coerce(value: any, helpers: any) { if (!value) { return { value }; } const number = isPlainObject(value) ? value.number : value; const country = isPlainObject(value) ? value.country : 'ZA'; const formattedNumber = require('phone')(number, country)[0]; if (formattedNumber && formattedNumber.length > 0) { return { value: formattedNumber }; } return { errors: helpers.error('cellphone.invalidForCountry', { country }) }; }, }), (joi: any) => ({ type: 'dateOfBirth', base: joi.string(), messages: { 'dateOfBirth.invalidFormat': '{{#label}} should be a valid date of birth in format {{#format}}', }, coerce(value: any, helpers: any) { if (!value) { return { value }; } let format: any; const dateFormat = helpers.schema.$_getFlag('dateFormat'); if (dateFormat) { format = dateFormat; } else if (value.length === 8) { format = 'YYYYMMDD'; } else { format = 'YYYY-MM-DD'; } const date = moment(value, format, true); if (!date.isValid() || date > moment().startOf('day')) { return { errors: helpers.error('dateOfBirth.invalidFormat', { format }) }; } return { value }; }, rules: { format: { method(format: string) { return this.$_setFlag('dateFormat', format); }, }, }, }), (joi: any) => ({ type: 'shortDate', base: joi.string(), messages: { 'shortDate.invalidFormat': '{{#label}} should be a valid date in format {{#format}}', }, coerce(value: any, helpers: any) { if (!value) { return { value }; } const format = helpers.schema.$_getFlag('dateFormat') || 'YYYY-MM-DD'; const date = moment(value, format, true); if (!date.isValid() || date > moment().startOf('day')) { return { errors: helpers.error('shortDate.invalidFormat', { format }) }; } return { value }; }, rules: { format: { method(format: string) { return this.$_setFlag('dateFormat', format); }, }, }, }), (joi: any) => ({ type: 'queryStringArray', base: joi.array(), coerce(value: any) { if (!value) { return { value }; } return { value: decodeURIComponent(value).split(',') }; }, }), ); // Backward compatibility: Joi v18 reworded several built-in messages (e.g. "greater than" was // "larger than" in v11). These overrides restore the v11 wording so product modules whose // tests assert on error text keep passing. const v11Messages: Record = { 'date.greater': '{{#label}} must be larger than "{{#limit}}"', 'date.less': '{{#label}} must be less than "{{#limit}}"', 'date.min': '{{#label}} must be larger than or equal to "{{#limit}}"', 'date.max': '{{#label}} must be less than or equal to "{{#limit}}"', 'number.greater': '{{#label}} must be larger than {{#limit}}', 'number.less': '{{#label}} must be less than {{#limit}}', 'number.min': '{{#label}} must be larger than or equal to {{#limit}}', 'number.max': '{{#label}} must be less than or equal to {{#limit}}', 'array.min': '{{#label}} must contain at least {{#limit}} items', 'array.max': '{{#label}} must contain less than or equal to {{#limit}} items', 'string.min': '{{#label}} length must be at least {{#limit}} characters long', 'string.max': '{{#label}} length must be less than or equal to {{#limit}} characters long', // Joi v18 drops "one of " when there's a single valid value; v11 always included it. 'any.only': '{{#label}} must be one of {{#valids}}', }; // Rebuild error.message using v11's "child \"X\" fails because [...]" wrapping for nested paths. // Joi v11 concatenated ancestor keys into the top-level message; v18 keeps only the leaf message. const rebuildV11Message = (error: any): void => { if (!error || !Array.isArray(error.details) || error.details.length === 0) return; const wrapDetail = (detail: any): string => { const path: Array = Array.isArray(detail.path) ? detail.path : []; let msg: string = detail.message; for (let i = path.length - 1; i >= 0; i -= 1) { if (path[i] === '' || path[i] === undefined) continue; msg = `child "${path[i]}" fails because [${msg}]`; } return msg; }; error.message = error.details.map((d: any) => wrapDetail(d)).join('. '); }; /** * Build a fresh options object with v11-compat fields merged in — DOES NOT mutate the * caller's input. Mirrors the non-mutating pattern the `.when({ else })` wrapper uses, * so a product module that reuses or snapshots the options object across multiple * `schema.validate(data, opts)` / `Joi.validate(data, schema, opts)` calls sees its * original shape preserved. * * The three injected fields restore v11 behaviour: * - `errors.label = 'key'` — v11 used the leaf key in error labels; v18 defaults to full paths. * - `messages` spread over `v11Messages` — restores wording like "larger than" vs v18's "greater than". * - `dateFormat = 'string'` — v11 rendered Date limits via `Date.prototype.toString()` * ("Fri Apr 17 2026 08:59:03 GMT+0200..."), v18 defaults to ISO-8601. */ const mergeV11ValidateOptions = (options: any): any => ({ ...(options ?? {}), errors: { label: 'key', ...options?.errors }, messages: { ...v11Messages, ...options?.messages }, dateFormat: options?.dateFormat ?? 'string', }); // Backward compatibility: Joi v11 accepted array arguments for valid/invalid/allow, // but Joi v18 requires spread arguments. Auto-flatten so product modules work with either syntax. // Joi v18 defines these methods on the `Any` base prototype; Joi.extend() deep-clones per-type // prototypes but `valid/invalid/allow` live on the shared base further up the chain. We must // walk the full prototype chain to find and patch the definition site for every schema type. const patched = new Set(); const allTypes = extendedJoi.types(); const patchProto = (proto: any): void => { if (!proto || patched.has(proto)) return; patched.add(proto); // Patch the canonical methods plus Joi v18's prototype-level aliases: // equal → valid; not/deny/disallow → invalid // The aliases are assigned as direct references to the original functions at module load // (see joi/lib/base.js), so they keep the array-rejecting behavior unless we rewrap them too. // NOTE: Joi v18 also aliases on the Base prototype (see joi/lib/base.js §Aliases): // equal → valid, not/deny/disallow → invalid, options/preferences → prefs. // The value-taking aliases below are covered; `options`/`preferences` don't accept // value arrays so they don't need the flatten shim. If a future Joi version aliases // another value-taking method, add it here. for (const method of ['valid', 'invalid', 'allow', 'equal', 'not', 'deny', 'disallow'] as const) { if (Object.prototype.hasOwnProperty.call(proto, method)) { const original = proto[method]; proto[method] = function (...args: any[]) { // Joi v11's `_valueValid` did `Hoek.flatten([].concat(values))` — a fully recursive // flatten. So `.valid(a, b)`, `.valid([a, b])`, `.valid([[a, b]])`, and // `.valid([a, [b, c]])` all produced a flat list of separate valid values; nested // arrays were never treated as literal valid values in v11. // // Joi v18 rejects any array arg via `verifyFlat` (common.js). A single-level peel // is not enough: product modules that compose valids via `.valid(...CONST_A, // ...CONST_B)` or `.valid([a, ARRAY_CONST])` still end up passing an inner array // through and hit `Method no longer accepts array arguments: valid`. Do the full // recursive flatten to match v11 semantics exactly. const flat = args.flat(Number.POSITIVE_INFINITY); return original.apply(this, flat); }; } } // Backward compatibility: Joi v11 used `else` in .when(), but Joi v18 renamed it to `otherwise`. // Use bracket access (rather than `{ else: elseVal, ...rest }` destructuring) because `else` is // a reserved word — destructuring it as a bare key survives modern transpilers but is needlessly // fragile, especially now that product-module TS bundles run through esbuild. Also: build a // fresh `rest` object instead of mutating `options` so callers reusing an options instance // across multiple schemas see the original shape on subsequent calls. if (Object.prototype.hasOwnProperty.call(proto, 'when')) { const originalWhen = proto.when; proto.when = function (condition: any, options: any) { if (options && typeof options === 'object' && !Array.isArray(options) && 'else' in options) { const rest: Record = { ...options }; const elseVal = rest.else; delete rest.else; options = { ...rest, otherwise: elseVal }; } return originalWhen.call(this, condition, options); }; } // Backward compatibility: Joi v11 allowed .error() callbacks to return plain objects like { message: '...' }, // but Joi v18 requires returning an Error instance. Wrap the callback to auto-convert. // Additionally, Joi v18 returns the Error directly (as an "override") without populating `details`, // but Joi v11 always created a ValidationError with a `details` array. We create an Error subclass // with a `details` property to maintain backward compatibility. Ensure `details[i].path` is always // an array so product-module helpers like `mapJoiValidationErrors` (which call `detail.path.join(...)`) // don't blow up with "Cannot read properties of undefined (reading 'join')". if (Object.prototype.hasOwnProperty.call(proto, 'error')) { const originalErrorMethod = proto.error; proto.error = function (err: any) { if (typeof err === 'function') { const originalFn = err; err = function (errors: any) { // Joi v18 passes an array of `Report` objects to the callback. v11 product-module // helpers (e.g. `mapJoiValidationErrors`) expect v11 error shape: // error.type (v18 has error.code) // error.context (v18 has error.local) // error.path (always array) // `path` and `local` are real Report fields that callers commonly read and spread, // so they stay enumerable — plain assignment is enough. `type` and `context` are // SYNTHESIZED aliases that duplicate `code` / `local`; define them non-enumerable // so `{...e}` spreads and `for (const k in e)` don't see both sides of the pair. if (Array.isArray(errors)) { for (const e of errors) { if (!e || typeof e !== 'object') continue; if (!Array.isArray(e.path)) e.path = []; if (e.local === undefined) e.local = {}; if (e.type === undefined && typeof e.code === 'string') { try { Object.defineProperty(e, 'type', { value: e.code, enumerable: false, configurable: true, writable: true, }); } catch { e.type = e.code; } } if (e.context === undefined) { try { Object.defineProperty(e, 'context', { value: e.local, enumerable: false, configurable: true, writable: true, }); } catch { e.context = e.local; } } } } const result = originalFn(errors); // Preserve v11 details shape (`{ message, path, type, context }`) in the final // ValidationError.details by mutating the original Report's `.message` and // returning the Report itself. Joi v18's details builder only produces // `{ message, path, type, context }` when the item is a Report — if it's a // wrapping Error, details[i] becomes just the Error (no `.context`). // Fallback: wrap a plain object as an Error with v11-shaped details. Used when we // either can't find a matching original Report OR when mutating the Report's message // silently fails (e.g. frozen / non-configurable), so callers never see stale text. const wrapAsError = (item: any): Error => { const msg = item?.message || 'Validation error'; const wrapped: any = new Error(msg); wrapped.details = [ { message: msg, path: Array.isArray(item?.path) ? item.path : [], type: item?.type || item?.code || 'override', context: item?.context || item?.local || { label: 'value' }, }, ]; return wrapped; }; const toReturnItem = (item: any, idx: number): any => { if (item instanceof Error) return item; const orig = Array.isArray(errors) && idx < errors.length ? errors[idx] : null; if (item && typeof item === 'object' && orig && typeof orig === 'object') { if (typeof item.message !== 'string') return orig; // Joi v18's Report.message is a plain writable own property (see Report constructor // in joi/lib/errors.js — `this.message = null`). Direct assignment is the canonical // path. Skip it if the descriptor says the slot is non-writable to avoid a silent // strict-mode throw, and verify post-write that the value actually took — this is // the load-bearing check: if Joi is ever upgraded and `message` becomes a getter, // or a plugin freezes the Report, assignment succeeds syntactically but produces // stale text. Falling through to `wrapAsError` in that case preserves the custom // message in a v11-shaped Error + details array. const descriptor = Object.getOwnPropertyDescriptor(orig, 'message'); const isWritable = descriptor ? descriptor.writable !== false : true; if (isWritable) { try { orig.message = item.message; if (orig.message === item.message) return orig; } catch { // fall through to defineProperty } } try { Object.defineProperty(orig, 'message', { value: item.message, writable: true, configurable: true, }); if (orig.message === item.message) return orig; } catch { // fall through to Error wrapper } return wrapAsError(item); } return wrapAsError(item); }; if (Array.isArray(result)) { return result.map(toReturnItem); } if (result && typeof result === 'object' && !(result instanceof Error)) { return toReturnItem(result, 0); } return result; }; } return originalErrorMethod.call(this, err); }; } // Backward compatibility: Joi v11 returned { error: null } on success for schema.validate(), // but Joi v18 returns { error: undefined }. Normalize to null for product modules that check `error === null`. // Also, Joi v11 used only the key name in error labels, but Joi v18 defaults to full paths. // Inject `errors: { label: 'key' }` to restore v11 behavior. if (Object.prototype.hasOwnProperty.call(proto, 'validate')) { const originalValidate = proto.validate; proto.validate = function (value: any, options?: any) { const result = originalValidate.call(this, value, mergeV11ValidateOptions(options)); if (result.error === undefined) { result.error = null; } else if (result.error) { if (Array.isArray(result.error.details)) { for (const d of result.error.details) { if (!Array.isArray(d.path)) d.path = []; } } rebuildV11Message(result.error); } return result; }; } }; // Walk the full prototype chain for every schema type so inherited methods on the Any base // (e.g. valid/invalid/allow in Joi v18) are patched once, at their definition site. for (const typeSchema of Object.values(allTypes)) { let proto = Object.getPrototypeOf(typeSchema); while (proto && proto !== Object.prototype) { patchProto(proto); proto = Object.getPrototypeOf(proto); } } // Backward compatibility: Joi v11 had Joi.validate(data, schema, options) // but Joi v17+ removed it. Provide a shim so product modules work. // Also normalize the result: Joi v11 returned { error: null } on success, // but Joi v18 returns { error: undefined }. Product modules check `error === null`. // Also inject `errors: { label: 'key' }` to restore v11 label behavior. extendedJoi.validate = function (data: any, schema: any, options?: any) { const result = schema.validate(data, mergeV11ValidateOptions(options)); if (result.error === undefined) { result.error = null; } else if (result.error) { if (Array.isArray(result.error.details)) { for (const d of result.error.details) { if (!Array.isArray(d.path)) d.path = []; } } rebuildV11Message(result.error); } return result; }; module.exports = extendedJoi;