diff --git a/package-lock.json b/package-lock.json index 2f916b83..b779eba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,6 @@ "html-to-text": "^9.0.5", "http-errors": "^2.0.0", "ioredis": "^5.4.1", - "is-disposable-email-domain": "^1.0.7", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.30.1", @@ -67,7 +66,7 @@ "qrcode": "^1.5.4", "rate-limiter-flexible": "^2.4.2", "tld-extract": "^2.1.0", - "tsx": "^4.17.0", + "tsx": "^4.19.2", "typescript": "^5.5.4", "vite": "^5.2.14", "zod": "^3.23.8", @@ -78,28 +77,28 @@ "@changesets/cli": "^2.27.10", "@simplewebauthn/types": "^10.0.0", "@sinonjs/fake-timers": "^11.2.2", - "@types/chai": "^5.0.0", + "@types/chai": "^5.0.1", "@types/chai-as-promised": "^7.1.8", "@types/html-to-text": "^9.0.4", "@types/http-errors": "^2.0.4", "@types/lodash": "^4.17.10", "@types/lodash-es": "^4.17.12", - "@types/mocha": "^10.0.7", - "@types/node": "^22.1.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", "@types/nodemailer": "^6.4.16", "@types/oidc-provider": "^8.5.2", "@types/qrcode": "^1.5.5", "@types/sinonjs__fake-timers": "^8.1.5", "axe-core": "^4.8.4", - "chai": "^5.1.1", - "chai-as-promised": "^8.0.0", + "chai": "^5.1.2", + "chai-as-promised": "^8.0.1", "concurrently": "^9.0.1", "copy-and-watch": "^0.1.6", "csv": "^6.3.9", "cypress": "^13.15.2", "cypress-axe": "^1.5.0", "cypress-maildev": "^1.3.2", - "mocha": "^10.7.3", + "mocha": "^11.0.1", "nock": "^13.5.4", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0" @@ -2397,11 +2396,14 @@ } }, "node_modules/@types/chai": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.0.tgz", - "integrity": "sha512-+DwhEHAaFPPdJ2ral3kNHFQXnTfscEEFsUxzD+d7nlcLrFK23JtNjH71RGasTcHb88b4vVi4mTyfpf8u2L8bdA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz", + "integrity": "sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } }, "node_modules/@types/chai-as-promised": { "version": "7.1.8", @@ -2469,6 +2471,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", @@ -2585,9 +2594,9 @@ "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" }, "node_modules/@types/mocha": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", - "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true, "license": "MIT" }, @@ -2600,12 +2609,12 @@ } }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.20.0" } }, "node_modules/@types/nodemailer": { @@ -3495,9 +3504,9 @@ } }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", "dev": true, "license": "MIT", "dependencies": { @@ -3512,11 +3521,11 @@ } }, "node_modules/chai-as-promised": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.0.tgz", - "integrity": "sha512-sMsGXTrS3FunP/wbqh/KxM8Kj/aLPXQGkNtvE5wPfSToq8wkkvBpTZo1LIiEVmC4BwkKpag+l5h/20lBMk6nUg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz", + "integrity": "sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA==", "dev": true, - "license": "WTFPL", + "license": "MIT", "dependencies": { "check-error": "^2.0.0" }, @@ -6893,9 +6902,9 @@ "peer": true }, "node_modules/mocha": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", - "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.0.1.tgz", + "integrity": "sha512-+3GkODfsDG71KSCQhc4IekSW+ItCK/kiez1Z28ksWvYhKXV/syxMlerR/sC7whDp7IyreZ4YxceMLdTs5hQE8A==", "dev": true, "license": "MIT", "dependencies": { @@ -6906,7 +6915,7 @@ "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", @@ -6925,7 +6934,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -6933,6 +6942,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -6963,19 +6973,37 @@ "license": "MIT" }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9410,9 +9438,9 @@ } }, "node_modules/tsx": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.17.0.tgz", - "integrity": "sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", "license": "MIT", "dependencies": { "esbuild": "~0.23.0", @@ -9548,9 +9576,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, "node_modules/universalify": { @@ -10365,14 +10393,18 @@ "license": "MIT", "dependencies": { "@zootools/email-spell-checker": "^1.12.0", - "is-disposable-email-domain": "^1.0.7" + "is-disposable-email-domain": "^1.0.7", + "lodash-es": "^4.17.21", + "tld-extract": "^2.1.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/mocha": "^10.0.7", - "chai": "^5.1.1", - "mocha": "^10.7.3", - "tsx": "^4.17.0" + "@types/lodash-es": "^4.17.12", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "tsx": "^4.19.2" } }, "packages/email": { diff --git a/package.json b/package.json index 3facc92c..81782c98 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "html-to-text": "^9.0.5", "http-errors": "^2.0.0", "ioredis": "^5.4.1", - "is-disposable-email-domain": "^1.0.7", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.30.1", @@ -101,7 +100,7 @@ "qrcode": "^1.5.4", "rate-limiter-flexible": "^2.4.2", "tld-extract": "^2.1.0", - "tsx": "^4.17.0", + "tsx": "^4.19.2", "typescript": "^5.5.4", "vite": "^5.2.14", "zod": "^3.23.8", @@ -112,28 +111,28 @@ "@changesets/cli": "^2.27.10", "@simplewebauthn/types": "^10.0.0", "@sinonjs/fake-timers": "^11.2.2", - "@types/chai": "^5.0.0", + "@types/chai": "^5.0.1", "@types/chai-as-promised": "^7.1.8", "@types/html-to-text": "^9.0.4", "@types/http-errors": "^2.0.4", "@types/lodash": "^4.17.10", "@types/lodash-es": "^4.17.12", - "@types/mocha": "^10.0.7", - "@types/node": "^22.1.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", "@types/nodemailer": "^6.4.16", "@types/oidc-provider": "^8.5.2", "@types/qrcode": "^1.5.5", "@types/sinonjs__fake-timers": "^8.1.5", "axe-core": "^4.8.4", - "chai": "^5.1.1", - "chai-as-promised": "^8.0.0", + "chai": "^5.1.2", + "chai-as-promised": "^8.0.1", "concurrently": "^9.0.1", "copy-and-watch": "^0.1.6", "csv": "^6.3.9", "cypress": "^13.15.2", "cypress-axe": "^1.5.0", "cypress-maildev": "^1.3.2", - "mocha": "^10.7.3", + "mocha": "^11.0.1", "nock": "^13.5.4", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0" diff --git a/packages/core/package.json b/packages/core/package.json index e953d5d7..4e1360a4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,15 +20,15 @@ "exports": { "./*": { "require": { - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" }, "import": { - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" }, - "types": "./dist/*", - "default": "./dist/*" + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" } }, "scripts": { @@ -46,14 +46,18 @@ }, "dependencies": { "@zootools/email-spell-checker": "^1.12.0", - "is-disposable-email-domain": "^1.0.7" + "is-disposable-email-domain": "^1.0.7", + "lodash-es": "^4.17.21", + "tld-extract": "^2.1.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.0", - "@types/mocha": "^10.0.7", - "chai": "^5.1.1", - "mocha": "^10.7.3", - "tsx": "^4.17.0" + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "@types/lodash-es": "^4.17.12", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "tsx": "^4.19.2" }, "publishConfig": { "access": "public", diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts new file mode 100644 index 00000000..83ae5ab7 --- /dev/null +++ b/packages/core/src/security/index.ts @@ -0,0 +1,7 @@ +// + +export * from "./is-domain-valid.js"; +export * from "./is-email-valid.js"; +export * from "./is-name-valid.js"; +export * from "./is-phone-number-valid.js"; +export * from "./is-siret-valid.js"; diff --git a/packages/core/src/security/is-domain-valid.test.ts b/packages/core/src/security/is-domain-valid.test.ts new file mode 100644 index 00000000..ea5f4b2b --- /dev/null +++ b/packages/core/src/security/is-domain-valid.test.ts @@ -0,0 +1,27 @@ +// + +import { assert } from "chai"; +import { isDomainValid } from "./is-domain-valid.js"; + +// + +describe("isDomainValid", () => { + it("should return false for undefined value", () => { + assert.equal(isDomainValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isDomainValid(""), false); + }); + + it("should return false if contains characters other than number and letters", () => { + assert.equal(isDomainValid("héééééé"), false); + }); + + it("should allow dot less tld", () => { + assert.equal(isDomainValid("co.uk"), true); + }); + it("should allow gouv.fr", () => { + assert.equal(isDomainValid("gouv.fr"), true); + }); +}); diff --git a/packages/core/src/security/is-domain-valid.ts b/packages/core/src/security/is-domain-valid.ts new file mode 100644 index 00000000..1c084e3a --- /dev/null +++ b/packages/core/src/security/is-domain-valid.ts @@ -0,0 +1,28 @@ +// + +import { isEmpty, isString } from "lodash-es"; +import { parse_host } from "tld-extract"; + +// + +/* + * specifications of these functions can be found at + * https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html#email-address-validation + */ +export function isDomainValid(domain: unknown): domain is string { + if (!isString(domain) || isEmpty(domain)) { + return false; + } + + if (domain.match(/^[a-zA-Z0-9.-]*$/) === null) { + return false; + } + + try { + parse_host(domain, { allowDotlessTLD: true }); + } catch { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-email-valid.test.ts b/packages/core/src/security/is-email-valid.test.ts new file mode 100644 index 00000000..c1e74f73 --- /dev/null +++ b/packages/core/src/security/is-email-valid.test.ts @@ -0,0 +1,70 @@ +import { assert } from "chai"; +import { isEmailValid } from "./is-email-valid.js"; + +describe("isEmailValid", () => { + it("should return false for undefined value", () => { + assert.equal(isEmailValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isEmailValid(""), false); + }); + + it("should return false if no @ is present", () => { + assert.equal(isEmailValid("test"), false); + }); + + it("should return false if no domain is present", () => { + assert.equal(isEmailValid("test@"), false); + }); + + it("should return false if two @ are present", () => { + assert.equal(isEmailValid("test@test@test"), false); + }); + + it("should return false if domains contain other than letters, numbers, hyphens (-) and periods (.)", () => { + assert.equal(isEmailValid("test@test_test"), false); + }); + + it("should return false if tld has the wrong case", () => { + assert.equal(isEmailValid("jean@wanadoo.Fr"), false); + }); + + it("should return false if local part is longer than 63 characters", () => { + assert.equal( + isEmailValid( + "1234567890123456789012345678901234567890123456789012345678901234@test", + ), + false, + ); + }); + + it("should return false if total length is longer than 254 characters", () => { + assert.equal( + isEmailValid( + "test@1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", + ), + false, + ); + }); + + // this test cases have been taken from + // https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript/32686261#32686261 + const validEmailAddresses = [ + "prettyandsimple@example.com", + "very.common@example.com", + "disposable.style.email.with+symbol@example.com", + "other.email-with-dash@example.com", + "#!$%&'*+-/=?^_`{}|~@example.org", + '"()[]:,;\\"!#$%&\'*+-/=?^_`{}| ~.a"@example.org', + '" "@example.org', // space between the quotes + "üñîçøðé@example.com", // Unicode characters in local part + "Pelé@example.com", // Latin + ]; + + validEmailAddresses.forEach((validEmailAddress) => { + it("should return true for valid email address", () => { + assert.equal(isEmailValid(validEmailAddress), true); + }); + }); +}); diff --git a/packages/core/src/security/is-email-valid.ts b/packages/core/src/security/is-email-valid.ts new file mode 100644 index 00000000..c09e4ef5 --- /dev/null +++ b/packages/core/src/security/is-email-valid.ts @@ -0,0 +1,44 @@ +// + +import { isEmpty, isString } from "lodash-es"; +import { Buffer } from "node:buffer"; +import { isDomainValid } from "./is-domain-valid.js"; + +// + +export function isEmailValid(email: unknown): email is string { + if (!isString(email) || isEmpty(email)) { + return false; + } + + const parts = email.split("@").filter((part) => part); + + // The email address contains two parts, separated with an @ symbol. + // => these parts are non-empty strings + // => there are two and only two parts + if (parts.length !== 2) { + return false; + } + + // The email address does not contain dangerous characters + // => the postgres connector is taking care of this + + // The domain part contains only letters, numbers, hyphens (-) and periods (.) + const domain = parts[1]; + if (!isDomainValid(domain)) { + return false; + } + + // The local part (before the @) should be no more than 63 characters. + const localPart = parts[0]; + if (Buffer.from(localPart).length > 63) { + return false; + } + + // The total length should be no more than 254 characters. + if (Buffer.from(email).length > 254) { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-name-valid.test.ts b/packages/core/src/security/is-name-valid.test.ts new file mode 100644 index 00000000..3a9fef27 --- /dev/null +++ b/packages/core/src/security/is-name-valid.test.ts @@ -0,0 +1,61 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isNameValid } from "./is-name-valid.js"; + +// + +describe("isNameValid", () => { + const invalidNames = [ + "jean@domaine.fr", + "dsi_etudes_applications", + "R2 - Sebastien", + "0000", + "CCTV70", + "0623456789", + ";GOUZE", + "Agathe/Carine", + ``, + "SG/PAFF/DDTM06", + "Jean*Robert", + "Jose_luis", + "MME.", + "Sabrina.b", + "M.Christine", + "Bousbecque59098*", + "vAL2RIE", + "Ch.", + "YOANNI TH.", + "M. le Président", + ]; + + invalidNames.forEach((invalidName) => { + it(`should return false for invalid names: ${invalidName}`, () => { + assert.equal(isNameValid(invalidName), false); + }); + }); + + const validNames = [ + "Jean", + "Jean-Jean", + "TAREK WAJDI", + " Tania", + "Надежда", + "沃德天·", + "อาทิตย์ นาถมทอง", + "俊宇", + "Doğan", + "Hanåğğne", + "سليمان خالد", + "marcn bh", + "THỊ PHƯƠNG HỒNG", + "Yamina⁵", + ]; + + validNames.forEach((validName) => { + it(`should return true for valid names: ${validName}`, () => { + assert.equal(isNameValid(validName), true); + }); + }); +}); diff --git a/packages/core/src/security/is-name-valid.ts b/packages/core/src/security/is-name-valid.ts new file mode 100644 index 00000000..09a61077 --- /dev/null +++ b/packages/core/src/security/is-name-valid.ts @@ -0,0 +1,5 @@ +// + +export function isNameValid(name: string) { + return !!name.match(/^[^$&+:;=?@#|<>.^*()%!\d_\[\]{}\\\/"`~]*$/); +} diff --git a/packages/core/src/security/is-phone-number-valid.test.ts b/packages/core/src/security/is-phone-number-valid.test.ts new file mode 100644 index 00000000..4e7f2bc0 --- /dev/null +++ b/packages/core/src/security/is-phone-number-valid.test.ts @@ -0,0 +1,44 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isPhoneNumberValid } from "./is-phone-number-valid.js"; + +// + +describe("isPhoneNumberValid", () => { + [ + undefined, + null, + 0, + true, + "📞", + "FR", + "Jean Michel", + "$", + "&", + "(", + ")", + "+33210", + ].forEach((name) => { + it(`should return false for "${name}"`, () => { + assert.equal(isPhoneNumberValid(name), false); + }); + }); + + it("should return true for '0123456789'", () => { + assert.equal(isPhoneNumberValid("0123456789"), true); + }); + + it("should return true for '0-1-2-3-4-5-6-7-8-9'", () => { + assert.equal(isPhoneNumberValid("0-1-2-3-4-5-6-7-8-9"), true); + }); + + it("should return true for '+00123456'", () => { + assert.equal(isPhoneNumberValid("+00123456"), true); + }); + + it("should return true for '+33123456789'", () => { + assert.equal(isPhoneNumberValid("+33123456789"), true); + }); +}); diff --git a/packages/core/src/security/is-phone-number-valid.ts b/packages/core/src/security/is-phone-number-valid.ts new file mode 100644 index 00000000..e7532f26 --- /dev/null +++ b/packages/core/src/security/is-phone-number-valid.ts @@ -0,0 +1,19 @@ +// + +import { isEmpty, isString } from "lodash-es"; + +// + +export function isPhoneNumberValid( + phoneNumber: unknown, +): phoneNumber is string { + if (!isString(phoneNumber) || isEmpty(phoneNumber)) { + return false; + } + + if (!phoneNumber.match(/^\+?(?:[0-9][ -]?){6,14}[0-9]$/)) { + return false; + } + + return true; +} diff --git a/packages/core/src/security/is-siret-valid.test.ts b/packages/core/src/security/is-siret-valid.test.ts new file mode 100644 index 00000000..2aa9b48b --- /dev/null +++ b/packages/core/src/security/is-siret-valid.test.ts @@ -0,0 +1,36 @@ +// + +import { assert } from "chai"; +import { describe, it } from "mocha"; +import { isSiretValid } from "./is-siret-valid.js"; + +// + +describe("isSiretValid", () => { + it("should return false for undefined value", () => { + assert.equal(isSiretValid(undefined), false); + }); + + it("should return false for empty string", () => { + assert.equal(isSiretValid(""), false); + }); + + it("should return false if it contains characters other than number", () => { + assert.equal(isSiretValid("a2345678901234"), false); + }); + it("should return false if it contains more that 14 numbers", () => { + assert.equal(isSiretValid("123456789012345"), false); + }); + + it("should return false if it contains less that 14 numbers", () => { + assert.equal(isSiretValid("1234567890123"), false); + }); + + it("should return true if it contains exactly 14 numbers", () => { + assert.equal(isSiretValid("12345678901234"), true); + }); + + it("should return true if it contains exactly 14 numbers with spaces", () => { + assert.equal(isSiretValid(" 123 456 789\n\r01234 \n"), true); + }); +}); diff --git a/packages/core/src/security/is-siret-valid.ts b/packages/core/src/security/is-siret-valid.ts new file mode 100644 index 00000000..106cbf97 --- /dev/null +++ b/packages/core/src/security/is-siret-valid.ts @@ -0,0 +1,15 @@ +// + +import { isEmpty, isString } from "lodash-es"; + +// + +export function isSiretValid(siret: unknown): siret is string { + if (!isString(siret) || isEmpty(siret)) { + return false; + } + + const siretNoSpaces = siret.replace(/\s/g, ""); + + return /^\d{14}$/.test(siretNoSpaces); +} diff --git a/packages/core/src/services/email/index.ts b/packages/core/src/services/email/index.ts new file mode 100644 index 00000000..da7061c2 --- /dev/null +++ b/packages/core/src/services/email/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./isAFreeDomain.js"; diff --git a/packages/core/src/services/suggestion/index.ts b/packages/core/src/services/suggestion/index.ts new file mode 100644 index 00000000..1352d188 --- /dev/null +++ b/packages/core/src/services/suggestion/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./did-you-mean.js"; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 1c2a9acc..23b7c277 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -5,7 +5,7 @@ "declarationMap": true, "outDir": "./dist", "rootDir": "src", - "types": ["./types"], + "types": ["node", "./types"], "module": "NodeNext", "moduleResolution": "nodenext", "verbatimModuleSyntax": true, diff --git a/packages/core/tsconfig.lib.json b/packages/core/tsconfig.lib.json index c3d239e6..3ba35441 100644 --- a/packages/core/tsconfig.lib.json +++ b/packages/core/tsconfig.lib.json @@ -1,16 +1,9 @@ { "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", - "types": [ - "./types" - ] + "rootDir": "./src" }, - "exclude": [ - "src/**/*.test.ts" - ], + "exclude": ["src/**/*.test.ts"], "extends": "./tsconfig.json", - "include": [ - "src" - ] + "include": ["src"] } diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 644b727e..a8c6994f 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -1,5 +1,4 @@ // -declare module "is-disposable-email-domain" { - function isFree(email: string): boolean; -} +/// +/// diff --git a/packages/core/types/is-disposable-email-domain.d.ts b/packages/core/types/is-disposable-email-domain.d.ts new file mode 100644 index 00000000..644b727e --- /dev/null +++ b/packages/core/types/is-disposable-email-domain.d.ts @@ -0,0 +1,5 @@ +// + +declare module "is-disposable-email-domain" { + function isFree(email: string): boolean; +} diff --git a/packages/core/types/tld-extract.d.ts b/packages/core/types/tld-extract.d.ts new file mode 100644 index 00000000..0211b457 --- /dev/null +++ b/packages/core/types/tld-extract.d.ts @@ -0,0 +1,5 @@ +// + +declare module "tld-extract" { + function parse_host(domain: string, { allowDotlessTLD: boolean }): boolean; +} diff --git a/scripts/import-accounts-coop.ts b/scripts/import-accounts-coop.ts index 0bbd9419..9ae3f438 100644 --- a/scripts/import-accounts-coop.ts +++ b/scripts/import-accounts-coop.ts @@ -1,4 +1,10 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isEmailValid, + isNameValid, + isPhoneNumberValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -22,12 +28,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { - isEmailValid, - isNameValid, - isPhoneNumberValid, - isSiretValid, -} from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/scripts/import-accounts.ts b/scripts/import-accounts.ts index 12789604..bf50e97f 100644 --- a/scripts/import-accounts.ts +++ b/scripts/import-accounts.ts @@ -1,4 +1,9 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isEmailValid, + isNameValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -22,11 +27,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { - isEmailValid, - isNameValid, - isSiretValid, -} from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/scripts/import-domains.ts b/scripts/import-domains.ts index 19560844..e967b79f 100644 --- a/scripts/import-domains.ts +++ b/scripts/import-domains.ts @@ -1,4 +1,8 @@ // src https://stackoverflow.com/questions/40994095/pipe-streams-to-edit-csv-file-in-node-js +import { + isDomainValid, + isSiretValid, +} from "@gouvfr-lasuite/proconnect.core/security"; import { AxiosError } from "axios"; import { parse, stringify, transform } from "csv"; import fs from "fs"; @@ -23,7 +27,6 @@ import { startDurationMesure, throttleApiCall, } from "../src/services/script-helpers"; -import { isDomainValid, isSiretValid } from "../src/services/security"; const { INPUT_FILE, OUTPUT_FILE } = z .object({ diff --git a/src/connectors/api-annuaire-education-nationale.ts b/src/connectors/api-annuaire-education-nationale.ts index e66373bb..9f026750 100644 --- a/src/connectors/api-annuaire-education-nationale.ts +++ b/src/connectors/api-annuaire-education-nationale.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { isEmpty, isString } from "lodash-es"; import { @@ -11,7 +12,6 @@ import { ApiAnnuaireNotFoundError, } from "../config/errors"; import { logger } from "../services/log"; -import { isEmailValid } from "../services/security"; type ApiAnnuaireEducationNationaleReponse = { total_count: number; diff --git a/src/connectors/api-annuaire-service-public.ts b/src/connectors/api-annuaire-service-public.ts index e9812df0..d5ac87a8 100644 --- a/src/connectors/api-annuaire-service-public.ts +++ b/src/connectors/api-annuaire-service-public.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import axios, { AxiosError, type AxiosResponse } from "axios"; import { isEmpty, isString } from "lodash-es"; import { @@ -12,7 +13,6 @@ import { ApiAnnuaireTooManyResultsError, } from "../config/errors"; import { logger } from "../services/log"; -import { isEmailValid } from "../services/security"; // more info at https://api-lannuaire.service-public.fr/api/explore/v2.1/console diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index 322a45db..1e5adfa7 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -1,3 +1,4 @@ +import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; import { Welcome } from "@gouvfr-lasuite/proconnect.email"; import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; @@ -53,7 +54,6 @@ import { isEtablissementScolaireDuPremierEtSecondDegre, isSmallAssociation, } from "../../services/organization"; -import { isEmailValid } from "../../services/security"; import { unableToAutoJoinOrganizationMd } from "../../views/mails/unable-to-auto-join-organization"; import { getOrganizationsByUserId, markDomainAsVerified } from "./main"; diff --git a/src/managers/user.ts b/src/managers/user.ts index b6bb4c0e..46ad07f7 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -1,3 +1,4 @@ +import { getDidYouMeanSuggestion } from "@gouvfr-lasuite/proconnect.core/services/suggestion"; import { Add2fa, AddAccessKey, @@ -12,6 +13,13 @@ import { VerifyEmail, } from "@gouvfr-lasuite/proconnect.email"; import { isEmpty } from "lodash-es"; +import { + MAGIC_LINK_TOKEN_EXPIRATION_DURATION_IN_MINUTES, + MAX_DURATION_BETWEEN_TWO_EMAIL_ADDRESS_VERIFICATION_IN_MINUTES, + MONCOMPTEPRO_HOST, + RESET_PASSWORD_TOKEN_EXPIRATION_DURATION_IN_MINUTES, + VERIFY_EMAIL_TOKEN_EXPIRATION_DURATION_IN_MINUTES, +} from "../config/env"; import { EmailUnavailableError, InvalidCredentialsError, @@ -26,15 +34,6 @@ import { } from "../config/errors"; import { isEmailSafeToSendTransactional } from "../connectors/debounce"; import { sendMail } from "../connectors/mail"; - -import { getDidYouMeanSuggestion } from "@gouvfr-lasuite/proconnect.core/services/suggestion/did-you-mean.js"; -import { - MAGIC_LINK_TOKEN_EXPIRATION_DURATION_IN_MINUTES, - MAX_DURATION_BETWEEN_TWO_EMAIL_ADDRESS_VERIFICATION_IN_MINUTES, - MONCOMPTEPRO_HOST, - RESET_PASSWORD_TOKEN_EXPIRATION_DURATION_IN_MINUTES, - VERIFY_EMAIL_TOKEN_EXPIRATION_DURATION_IN_MINUTES, -} from "../config/env"; import { hasPasswordBeenPwned } from "../connectors/pwnedpasswords"; import { create, diff --git a/src/services/custom-zod-schemas.ts b/src/services/custom-zod-schemas.ts index 0300bb86..4fc4f3fc 100644 --- a/src/services/custom-zod-schemas.ts +++ b/src/services/custom-zod-schemas.ts @@ -1,13 +1,12 @@ -import { z } from "zod"; -import { normalizeOfficialContactEmailVerificationToken } from "./normalize-official-contact-email-verification-token"; import { isEmailValid, isNameValid, - isNotificationLabelValid, isPhoneNumberValid, isSiretValid, - isVisibleString, -} from "./security"; +} from "@gouvfr-lasuite/proconnect.core/security"; +import { z } from "zod"; +import { normalizeOfficialContactEmailVerificationToken } from "./normalize-official-contact-email-verification-token"; +import { isNotificationLabelValid, isVisibleString } from "./security"; export const siretSchema = () => z diff --git a/src/services/email.ts b/src/services/email.ts index dfd01b9e..14093989 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,6 +1,6 @@ // -import { isAFreeDomain } from "@gouvfr-lasuite/proconnect.core/services/email/isAFreeDomain.js"; +import { isAFreeDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { parse_host } from "tld-extract"; import { FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_FREE, diff --git a/src/services/organization.ts b/src/services/organization.ts index ded90c16..668ecacb 100644 --- a/src/services/organization.ts +++ b/src/services/organization.ts @@ -1,4 +1,4 @@ -import { isDomainValid } from "./security"; +import { isDomainValid } from "@gouvfr-lasuite/proconnect.core/security"; /** * These fonctions return approximate results. As the data tranche effectifs is diff --git a/src/services/security.ts b/src/services/security.ts index da147904..f87c7bf5 100644 --- a/src/services/security.ts +++ b/src/services/security.ts @@ -1,7 +1,6 @@ import bcrypt from "bcryptjs"; import { hasIn, isEmpty, isString } from "lodash-es"; import { customAlphabet, nanoid } from "nanoid/async"; -import { parse_host } from "tld-extract"; import { MONCOMPTEPRO_HOST } from "../config/env"; import notificationMessages from "../config/notification-messages"; import dicewareWordlistFrAlt from "../data/diceware-wordlist-fr-alt"; @@ -52,88 +51,12 @@ export const isPasswordSecure = (plainPassword: string, email: string) => { return !containsBlacklistedWord && strong; }; -/* - * specifications of these functions can be found at - * https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html#email-address-validation - */ -export const isDomainValid = (domain: unknown): domain is string => { - if (!isString(domain) || isEmpty(domain)) { - return false; - } - - if (domain.match(/^[a-zA-Z0-9.-]*$/) === null) { - return false; - } - - try { - parse_host(domain, { allowDotlessTLD: true }); - } catch (error) { - return false; - } - - return true; -}; -export const isEmailValid = (email: unknown): email is string => { - if (!isString(email) || isEmpty(email)) { - return false; - } - - const parts = email.split("@").filter((part) => part); - - // The email address contains two parts, separated with an @ symbol. - // => these parts are non-empty strings - // => there are two and only two parts - if (parts.length !== 2) { - return false; - } - - // The email address does not contain dangerous characters - // => the postgres connector is taking care of this - - // The domain part contains only letters, numbers, hyphens (-) and periods (.) - const domain = parts[1]; - if (!isDomainValid(domain)) { - return false; - } - - // The local part (before the @) should be no more than 63 characters. - const localPart = parts[0]; - if (Buffer.from(localPart).length > 63) { - return false; - } - - // The total length should be no more than 254 characters. - if (Buffer.from(email).length > 254) { - return false; - } - - return true; -}; - -export const isPhoneNumberValid = ( - phoneNumber: unknown, -): phoneNumber is string => { - if (!isString(phoneNumber) || isEmpty(phoneNumber)) { - return false; - } - - if (!phoneNumber.match(/^\+?(?:[0-9][ -]?){6,14}[0-9]$/)) { - return false; - } - - return true; -}; - export const isVisibleString = (input: string) => { const visibleCharRegex = /[^\s\p{Cf}\p{Cc}\p{Zl}\p{Zp}]/u; return visibleCharRegex.test(input); }; -export const isNameValid = (name: string) => { - return !!name.match(/^[^$&+:;=?@#|<>.^*()%!\d_\[\]{}\\\/"`~]*$/); -}; - const nanoidPin = customAlphabet("0123456789", 10); export const generatePinToken = async () => { @@ -155,16 +78,6 @@ export const generateDicewarePassword = async () => { return `${dicewareWordlistFrAlt[firstFiveDices]}-${dicewareWordlistFrAlt[secondFiveDices]}`; }; -export const isSiretValid = (siret: unknown): siret is string => { - if (!isString(siret) || isEmpty(siret)) { - return false; - } - - const siretNoSpaces = siret.replace(/\s/g, ""); - - return /^\d{14}$/.test(siretNoSpaces); -}; - export const getTrustedReferrerPath = (referrer: unknown): string | null => { if (!isString(referrer) || isEmpty(referrer)) { return null; diff --git a/src/types/is-disposable-email-domain.d.ts b/src/types/is-disposable-email-domain.d.ts deleted file mode 100644 index 1b3715e5..00000000 --- a/src/types/is-disposable-email-domain.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "is-disposable-email-domain"; diff --git a/test/security.test.ts b/test/security.test.ts index 94324c54..be7bf02e 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -2,110 +2,10 @@ import { assert } from "chai"; import { MONCOMPTEPRO_HOST } from "../src/config/env"; import { getTrustedReferrerPath, - isEmailValid, - isNameValid, isPasswordSecure, - isSiretValid, isVisibleString, } from "../src/services/security"; -describe("isEmailValid", () => { - it("should return false for undefined value", () => { - assert.equal(isEmailValid(undefined), false); - }); - - it("should return false for empty string", () => { - assert.equal(isEmailValid(""), false); - }); - - it("should return false if no @ is present", () => { - assert.equal(isEmailValid("test"), false); - }); - - it("should return false if no domain is present", () => { - assert.equal(isEmailValid("test@"), false); - }); - - it("should return false if two @ are present", () => { - assert.equal(isEmailValid("test@test@test"), false); - }); - - it("should return false if domains contain other than letters, numbers, hyphens (-) and periods (.)", () => { - assert.equal(isEmailValid("test@test_test"), false); - }); - - it("should return false if tld has the wrong case", () => { - assert.equal(isEmailValid("jean@wanadoo.Fr"), false); - }); - - it("should return false if local part is longer than 63 characters", () => { - assert.equal( - isEmailValid( - "1234567890123456789012345678901234567890123456789012345678901234@test", - ), - false, - ); - }); - - it("should return false if total length is longer than 254 characters", () => { - assert.equal( - isEmailValid( - "test@1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", - ), - false, - ); - }); - - // this test cases have been taken from - // https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript/32686261#32686261 - const validEmailAddresses = [ - "prettyandsimple@example.com", - "very.common@example.com", - "disposable.style.email.with+symbol@example.com", - "other.email-with-dash@example.com", - "#!$%&'*+-/=?^_`{}|~@example.org", - '"()[]:,;\\"!#$%&\'*+-/=?^_`{}| ~.a"@example.org', - '" "@example.org', // space between the quotes - "üñîçøðé@example.com", // Unicode characters in local part - "Pelé@example.com", // Latin - ]; - - validEmailAddresses.forEach((validEmailAddress) => { - it("should return true for valid email address", () => { - assert.equal(isEmailValid(validEmailAddress), true); - }); - }); -}); - -describe("isSiretValid", () => { - it("should return false for undefined value", () => { - assert.equal(isSiretValid(undefined), false); - }); - - it("should return false for empty string", () => { - assert.equal(isSiretValid(""), false); - }); - - it("should return false if it contains characters other than number", () => { - assert.equal(isSiretValid("a2345678901234"), false); - }); - it("should return false if it contains more that 14 numbers", () => { - assert.equal(isSiretValid("123456789012345"), false); - }); - - it("should return false if it contains less that 14 numbers", () => { - assert.equal(isSiretValid("1234567890123"), false); - }); - - it("should return true if it contains exactly 14 numbers", () => { - assert.equal(isSiretValid("12345678901234"), true); - }); - - it("should return true if it contains exactly 14 numbers with spaces", () => { - assert.equal(isSiretValid(" 123 456 789\n\r01234 \n"), true); - }); -}); - describe("isVisibleString", () => { const nonVisibleStrings = [ "", @@ -131,60 +31,6 @@ describe("isVisibleString", () => { }); }); -describe("isNameValid", () => { - const invalidNames = [ - "jean@domaine.fr", - "dsi_etudes_applications", - "R2 - Sebastien", - "0000", - "CCTV70", - "0623456789", - ";GOUZE", - "Agathe/Carine", - ``, - "SG/PAFF/DDTM06", - "Jean*Robert", - "Jose_luis", - "MME.", - "Sabrina.b", - "M.Christine", - "Bousbecque59098*", - "vAL2RIE", - "Ch.", - "YOANNI TH.", - "M. le Président", - ]; - - invalidNames.forEach((invalidName) => { - it(`should return false for invalid names: ${invalidName}`, () => { - assert.equal(isNameValid(invalidName), false); - }); - }); - - const validNames = [ - "Jean", - "Jean-Jean", - "TAREK WAJDI", - " Tania", - "Надежда", - "沃德天·", - "อาทิตย์ นาถมทอง", - "俊宇", - "Doğan", - "Hanåğğne", - "سليمان خالد", - "marcn bh", - "THỊ PHƯƠNG HỒNG", - "Yamina⁵", - ]; - - validNames.forEach((validName) => { - it(`should return true for valid names: ${validName}`, () => { - assert.equal(isNameValid(validName), true); - }); - }); -}); - describe("isUrlTrusted", () => { it("should not trust null url", () => { assert.equal(getTrustedReferrerPath(null), null);