Explorar o código

PROD-1221: Initial commit of EiRegister endpoint

* VEN registration
* XML (de-)serialization
* Stub VEN used by integration tests
* docker-compose.yml infrastructure for running/testing
Blake Schneider %!s(int64=5) %!d(string=hai) anos
pai
achega
12b2d49e5a
Modificáronse 48 ficheiros con 6084 adicións e 1 borrados
  1. 7 0
      .dockerignore
  2. 27 0
      .eslintrc.json
  3. 7 0
      .gitignore
  4. 11 0
      .prettierrc
  5. 23 0
      Dockerfile
  6. 13 0
      Dockerfile.test
  7. 97 1
      README.md
  8. 25 0
      __tests__/integration/integration-client.crt
  9. 35 0
      __tests__/integration/ven-registration.spec.js
  10. 45 0
      __tests__/unit/db/ven.spec.js
  11. 23 0
      __tests__/unit/modules/certificate.spec.js
  12. 120 0
      __tests__/unit/processes/registration.spec.js
  13. 64 0
      __tests__/unit/xml/create-party-registration.spec.js
  14. 45 0
      __tests__/unit/xml/created-party-registration.spec.js
  15. 19 0
      __tests__/unit/xml/js-requests.js
  16. 15 0
      __tests__/unit/xml/js-responses.js
  17. 85 0
      __tests__/unit/xml/xml-requests.js
  18. 85 0
      client/ven.js
  19. 19 0
      config/development.js
  20. 12 0
      config/index.js
  21. 18 0
      config/production.js
  22. 18 0
      config/testing.js
  23. 15 0
      db/_db.js
  24. 10 0
      db/index.js
  25. 7 0
      db/models/index.js
  26. 18 0
      db/models/ven.js
  27. 47 0
      docker-compose.yml
  28. 4 0
      docker.npmrc
  29. 8 0
      docker_build.sh
  30. 3 0
      docker_run_psql.sh
  31. 5 0
      docker_run_tests.sh
  32. 12 0
      index.js
  33. 49 0
      logger.js
  34. 30 0
      modules/certificate.js
  35. 43 0
      nginx.conf
  36. 4389 0
      package-lock.json
  37. 41 0
      package.json
  38. 87 0
      processes/registration.js
  39. 37 0
      server/controllers/register-party.js
  40. 40 0
      server/index.js
  41. 9 0
      server/middleware/async-handler.js
  42. 24 0
      server/middleware/certificate-parser.js
  43. 9 0
      server/middleware/index.js
  44. 9 0
      server/routes/index.js
  45. 13 0
      server/routes/register-party.js
  46. 128 0
      xml/create-party-registration.js
  47. 172 0
      xml/created-party-registration.js
  48. 62 0
      xml/parser.js

+ 7 - 0
.dockerignore

@@ -0,0 +1,7 @@
+.gitignore
+*.log
+/coverage
+.idea/
+.npmrc
+.npm
+/node_modules

+ 27 - 0
.eslintrc.json

@@ -0,0 +1,27 @@
+{
+  "extends": "eslint:recommended",
+  "env": {
+    "es6": true,
+    "node": true,
+    "mocha": true,
+    "commonjs": true,
+    "jest": true
+  },
+  "rules": {
+    "indent": ["error", 2, { "SwitchCase": 1 }],
+    "linebreak-style": ["error", "unix"],
+    "quotes": ["error", "single"],
+    "semi": ["error", "always"],
+    "no-console": ["warn", { "allow": ["info", "error"] }],
+    "arrow-parens": ["error", "as-needed"],
+    "strict": ["error", "global"]
+  },
+  "parserOptions": {
+    "ecmaVersion": 8,
+    "ecmaFeatures": {
+      "experimentalObjectRestSpread": true,
+      "jsx": true
+    },
+    "sourceType": "script"
+  }
+}

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules
+/.idea/
+/clientssl.crt
+/ssl.key
+/ssl.crt
+/.nyc_output/
+/.npmrc

+ 11 - 0
.prettierrc

@@ -0,0 +1,11 @@
+{
+  "printWidth": 80,
+  "singleQuote": true,
+  "trailingComma": "all",
+  "bracketSpacing": true,
+  "jsxBracketSameLine": false,
+  "semi": true,
+  "requirePragma": false,
+  "proseWrap": "preserve",
+  "arrowParens": "avoid"
+}

+ 23 - 0
Dockerfile

@@ -0,0 +1,23 @@
+FROM node:12-alpine
+
+RUN apk add --no-cache vim curl git bash
+
+WORKDIR /usr/src/app
+
+COPY package.json /usr/src/app
+
+COPY package-lock.json /usr/src/app
+
+ARG NPM_TOKEN
+
+COPY docker.npmrc /usr/src/app/.npmrc
+
+RUN npm ci
+
+RUN rm /usr/src/app/.npmrc
+
+COPY . /usr/src/app
+
+EXPOSE 8080
+
+CMD ["npm", "start"]

+ 13 - 0
Dockerfile.test

@@ -0,0 +1,13 @@
+FROM node:12-alpine
+
+RUN apk add --no-cache vim curl git bash
+
+WORKDIR /usr/src/app
+
+COPY package.json /usr/src/app
+
+COPY package-lock.json /usr/src/app
+
+COPY . /usr/src/app
+
+CMD ["npm", "test"]

+ 97 - 1
README.md

@@ -1 +1,97 @@
-OADR VTN
+# Overview
+
+A NodeJS web application providing OpenADR 2.0b services.
+
+# Configuration
+
+## Files
+RSA private key for Kinesis must be installed at `pem/private-key.pem`.
+
+## Environment
+Please set the following environment variables:
+* `COMPANY`: Which company we're associated to
+* `NODE_ENV`: Which environment we're running in. Can be `production` | `development` | `test`.
+* `DB_URL`: The database URL used to store buffered sensor readings
+* `ENCRYPT_PASS`: The password used to encrypt the RSA private key, as well as `LOGGER_PEM`
+* `PORT`: The TCP port the webserver should bind to
+* `REGION`: AWS region to use for Kinesis
+* `INSTANCE_ID`: `Instance ID` to be used by Kinesis-Logger
+* `LOGGER_PEM`: RSA private key contents to be used by Kinesis-Logger
+* `NO_AWS`: Set this true when `NODE_ENV` is `development` to be able to run outside an AWS environment
+
+## Running locally for development
+
+### Environment
+At a minimum you will want to set `NODE_ENV` to `development`, `NO_AWS` to `true`, and configure a `DB_URL` to point to
+a Postgres database.
+
+### Build
+Ensure you have a `.npmrc` file with an authToken for the `@hw` and `@be` private repos. If you get an error `E401` it's likely
+that this is mis-configured.
+
+Run
+
+```
+npm install
+```
+
+to install dependencies.
+
+### Running tests
+```
+npm run test
+```
+
+### Running server
+```
+npm run start
+```
+
+## Running in Docker for development
+
+### Configuration
+The following files should be present in the project directory and are referred to by `docker-compose.yml`
+
+* `ssl.crt`: VTN certificate file in PEM format. This will be the concatenated result of 3 certificates: 1) VTN cert TEST_RSA_VTN_2003XXXXXXXXX_cert.pem, 2) Root Cert Authority TEST_OpenADR_RSA_RCA0002_Cert.pem, 3) Service Provider TEST_OpenADR_RSA_SPCA0002_Cert.pem. Certificates must be present in that order.
+* `ssl.key`: VTN key file in PEM format. This will come from a file that looks like TEST_RSA_VTN_2003XXXXXXXXX_privkey.pem in the VTN cert bundle.
+* `clientssl.crt`: VEN CA certificate file in PEM format. This will be the concatenated result of 2 certificates from the VEN cert bundle (e.g. TEST_RSA_VEN_2003XXXXXXXXX_certs.zip): 1) Intermediate "MCA" cert TEST_OpenADR_RSA_MCA0002_Cert.pem, 2) Root "RCA" cert TEST_OpenADR_RSA_RCA0002_Cert.pem. Certificates must be present in that order. nginx uses this to validate client certificates. 
+ 
+### Build
+You will need an authToken for the `@hw` repo. You should be able to retrieve this by running `npm login https://[insert repo url here]`
+and looking in `~/.npmrc` or `./.npmrc`.
+
+Pass the auth token to `./docker_build.sh` like
+```
+env NPM_TOKEN=YOURTOKENHERE ./docker_build.sh
+```
+
+### Running tests
+First follow the `Build` steps above, then run
+
+```
+./docker_run_tests.sh
+```
+
+
+### Running server
+First follow the `Build` steps above, then run
+
+```
+docker-compose up -d
+```
+
+You can tweak the environment variables in `docker-compose.yml`.
+
+### Administering database
+You can run
+```
+./docker_run_psql.sh
+```
+
+To get a `psql` session for the Docker Postgres database.
+
+## Running locally with a Docker database
+
+If you don't want to spin up a separate Postgres database, you can follow the steps in `Running in Docker for development`,
+un-comment the 2 `port` lines under `db` in `docker-compose.yml`, then use a `DB_URL` of `postgres://vtn:vtn@127.0.0.1:55432/vtn_test`
+in your local NodeJS environment. This will let you change code quickly without rebuilding a Docker image.

+ 25 - 0
__tests__/integration/integration-client.crt

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEKjCCApKgAwIBAgIJAL3GccwScBBjMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV
+BAYTAlVTMRkwFwYDVQQKExBPcGVuQURSIEFsbGlhbmNlMRcwFQYDVQQLEw5SU0Eg
+VkVOIENBMDAwMTEpMCcGA1UEAxMgVEVTVCBPcGVuQURSIEFsbGlhbmNlIFJTQSBW
+RU4gQ0EwHhcNMjAwNDA3MjI0OTA5WhcNNDAwNDA3MjI0OTA5WjBvMQswCQYDVQQG
+EwJDQTEVMBMGA1UECgwMQkdTLkRFViBJbmMuMTIwMAYDVQQLDClURVNUIE9wZW5B
+RFIgQWxsaWFuY2UgUlNBIFZFTiBDZXJ0aWZpY2F0ZTEVMBMGA1UEAwwMMTEyMjMz
+NDQ1NTY2MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmjOvvqHyzw/T
+deh7wzL/e6NBL0lx8b0mDhkZMWcWL5ZlCGveyFpZbzB+MgXB9XVDDw9vXg5yyAZ+
+qYGtySVH52a6ubhHGEaQ4rgy8r4osnsWJOFJAB4s0N0cZ9Ymw7/L5M038f9+hbTl
+uspwYkwe2ExWzK2VPjiTRH/RbQj0J58tzI1MRI+b6s2SlhKvrE314vN9QgX56pho
+QVVCU1zvjaOrl8GsuHdSiXu4rhupTF8sgB+k4wpMWsYxIGBqfZ8EcriFXpyHabpe
+zJKeACmBt8sZ88ukSNj8z/WVwTDBWyEAZ4z5NnSmXe7H+/Zy2Uq+yrnbCnJwcqK1
+yWi3AX3/EwIDAQABo0wwSjAOBgNVHQ8BAf8EBAMCBaAwHwYDVR0jBBgwFoAUhZV8
+Mgit2zkFxKk01XoYMehk76owFwYDVR0gBBAwDjAMBgorBgEEAYLELwEBMA0GCSqG
+SIb3DQEBCwUAA4IBgQBmtOi4Che9PLpvx3wsKUGeAoxCjgOJa6pKBEhE/uoGaCvS
++W4itUtjO1xhUH4q3H0EDiR+M+EHL2z2h0gB8e6dVu4vTOrl6c71NXifumIlQBv3
+V0mUPzTk71O22CwJL447EVXrijhknJqeo0fn1MjGDvvyYxpFVHHxCUVOA7Cc9/QQ
+HifXSR/6DtJRkqt5VQjH5aw9WzhZEsBmdwS8t86cbl3uY4+VL3bkivQIs4PQx9VX
+/3jV57zFTYVAGe0mciDgSPSAVofaS7IkKdOe+zGTw7rTr2gQcBoePED/Wh0m0Zcr
+X2hVQs8Pch7O6omr1+hexc820AQ94O+sr86svxfmMFMUemo1y1SiqR1PqX41PzQ6
+N/r59BZ39jmElqzJgHMJp0CIcuAa/ev1M+nb3Td2ykR2gL2DW95nEP+CxHjVKVGW
+ifcf98aSKmWjx+gnkkgWDTDzoAkMMlBTm8xX6fa9jWnuzR9xkMOt8t6/gx5Q2g0T
+q9DfT7yEWXapD3rXkKg=
+-----END CERTIFICATE-----

+ 35 - 0
__tests__/integration/ven-registration.spec.js

@@ -0,0 +1,35 @@
+'use strict';
+
+const { readFileSync } = require('fs');
+const path = require('path');
+const { expect } = require('chai');
+
+const { Ven } = require('../../client/ven');
+const app = require('../../server');
+const { sequelize } = require('../../db');
+const { port } = require('../../config');
+
+describe('VEN registration', function() {
+
+  describe('successful self-registration', async function() {
+
+    let ven;
+
+    before(async () => {
+      await sequelize.sync();
+      await app.start();
+      const clientCrtPem = readFileSync(path.join(__dirname, 'integration-client.crt'), 'utf-8');
+      ven = new Ven(`http://127.0.0.1:${port}`, clientCrtPem, 'aabbccddeeff', '17:32:59:FD:0E:B5:99:31:27:9C', 'ven.js1');
+    });
+
+    it('should successfully register and receive a vtnId', async () => {
+      const registrationResponse = await ven.register();
+      expect(registrationResponse.vtnId).to.be.a('string');
+    });
+
+    after(async () => {
+      await app.stop();
+    });
+
+  });
+});

+ 45 - 0
__tests__/unit/db/ven.spec.js

@@ -0,0 +1,45 @@
+'use strict';
+
+const { expect } = require('chai');
+const { sequelize, Ven } = require('../../../db');
+const { v4 } = require('uuid');
+
+describe('VEN Model', function() {
+
+  before(async () => {
+    await sequelize.sync();
+  });
+  
+  describe('Retrieval', function() {
+
+    it('returns no results for unknown ven_id', async () => {
+      const randomVenId = v4();
+      const existing = await Ven.findOne({ where: { ven_id: randomVenId } });
+      expect(existing).to.be.null;
+    });
+
+    it('retrieves a saved record when queried by ven_id', async () => {
+      const randomVenId = v4();
+      const randomCommonName = v4();
+      const ven = new Ven();
+      ven.ven_id = randomVenId;
+      ven.common_name = randomCommonName;
+      await ven.save();
+      const existing = await Ven.findOne({ where: { ven_id: randomVenId } });
+      expect(existing).to.not.be.undefined;
+      expect(existing.common_name).to.eql(randomCommonName);
+    });
+
+    it('retrieves a saved record when queried by common_name', async () => {
+      const randomVenId = v4();
+      const randomCommonName = v4();
+      const ven = new Ven();
+      ven.ven_id = randomVenId;
+      ven.common_name = randomCommonName;
+      await ven.save();
+      const existing = await Ven.findOne({ where: { common_name: randomCommonName } });
+      expect(existing).to.not.be.undefined;
+      expect(existing.common_name).to.eql(randomCommonName);
+    });
+  });
+});

+ 23 - 0
__tests__/unit/modules/certificate.spec.js

@@ -0,0 +1,23 @@
+'use strict';
+
+const { readFileSync } = require('fs');
+const path = require('path');
+const { expect } = require('chai');
+const { escape } = require('querystring');
+
+const {
+  calculatePartialFingerprintOfEscapedPemCertificate,
+} = require('../../../modules/certificate');
+
+describe('Certificate management', function() {
+
+  describe('calculatePartialFingerprintOfEscapedPemCertificate', function() {
+
+    it ('generates correct fingerprint for integration certificate', async () => {
+      const clientCrtPem = readFileSync(path.join(__dirname, '..', '..', 'integration', 'integration-client.crt'), 'utf-8');
+      const escaped = escape(clientCrtPem);
+      const fingerprint = calculatePartialFingerprintOfEscapedPemCertificate(escaped);
+      expect(fingerprint).to.eql('17:32:59:FD:0E:B5:99:31:27:9C');
+    });
+  });
+});

+ 120 - 0
__tests__/unit/processes/registration.spec.js

@@ -0,0 +1,120 @@
+'use strict';
+
+const { expect } = require('chai');
+const { v4 } = require('uuid');
+const { sequelize } = require('../../../db');
+
+const {
+  registerParty,
+} = require('../../../processes/registration');
+
+describe('VEN registration', function() {
+
+  before(async () => {
+    await sequelize.sync();
+  });
+
+  describe('registerParty', function() {
+
+    let venId, commonName, registrationResponse;
+
+    before(async () => {
+      venId = v4().replace(/-/g, '').substring(0,20).toUpperCase().match(/.{2}/g).join(':');
+      const requestId = v4().replace(/-/g, '');
+      commonName = v4().replace(/-/g, '').substring(0, 12);
+      const request = {
+        requestId: requestId,
+        venId: venId,
+        oadrProfileName: '2.0b',
+        oadrTransportName: 'simplehttp',
+        oadrReportOnly: false,
+        oadrXmlSignature: false,
+        oadrVenName: `VEN ${commonName}`,
+        oadrHttpPullModel: true
+      };
+      registrationResponse = await registerParty(request, commonName, venId);
+    });
+
+    it ('allows registration of a new VEN', async () => {
+      expect(registrationResponse.registrationId).to.be.a('string');
+    });
+
+    it ('rejects VEN with non-matching venId', async () => {
+      const venId = v4().replace(/-/g, '').substring(0,20).toUpperCase().match(/.{2}/g).join(':');
+      const requestId = v4().replace(/-/g, '');
+      const commonName = v4().replace(/-/g, '').substring(0, 12);
+      const request = {
+        requestId: requestId,
+        venId: venId,
+        oadrProfileName: '2.0b',
+        oadrTransportName: 'simplehttp',
+        oadrReportOnly: false,
+        oadrXmlSignature: false,
+        oadrVenName: `VEN ${commonName}`,
+        oadrHttpPullModel: true
+      };
+
+      let exception;
+      try {
+        await registerParty(request, commonName, `${venId}:FF`);
+      } catch (e) {
+        exception = e;
+      }
+
+      expect(exception).is.an('error');
+      expect(exception.message).to.eql('VenID does not match certificate.');
+    });
+
+    it ('rejects registration when common name changes', async () => {
+      const requestId = v4().replace(/-/g, '');
+      const commonName2 = v4().replace(/-/g, '').substring(0, 12);
+      const request = {
+        requestId: requestId,
+        venId: venId,
+        oadrProfileName: '2.0b',
+        oadrTransportName: 'simplehttp',
+        oadrReportOnly: false,
+        oadrXmlSignature: false,
+        oadrVenName: `VEN ${commonName}`,
+        oadrHttpPullModel: true
+      };
+
+      let exception;
+      try {
+        await registerParty(request, commonName2, venId);
+      } catch (e) {
+        exception = e;
+      }
+
+      expect(exception).is.an('error');
+      expect(exception.message).to.eql('Client certificate CN mismatch.');
+    });
+
+    it ('rejects registration with existing common name but different venId', async () => {
+      const requestId = v4().replace(/-/g, '');
+      const venId2 = v4().replace(/-/g, '').substring(0,20).toUpperCase().match(/.{2}/g).join(':');
+
+      const request = {
+        requestId: requestId,
+        venId: venId2,
+        oadrProfileName: '2.0b',
+        oadrTransportName: 'simplehttp',
+        oadrReportOnly: false,
+        oadrXmlSignature: false,
+        oadrVenName: `VEN ${commonName}`,
+        oadrHttpPullModel: true
+      };
+
+      let exception;
+      try {
+        await registerParty(request, commonName, venId2);
+      } catch (e) {
+        exception = e;
+      }
+
+      expect(exception).is.an('error');
+      expect(exception.message).to.eql('Ven already exists with that CN.');
+    });
+
+  });
+});

+ 64 - 0
__tests__/unit/xml/create-party-registration.spec.js

@@ -0,0 +1,64 @@
+'use strict';
+
+const { expect } = require('chai');
+
+const { parse, serialize } = require('../../../xml/create-party-registration');
+const { createPartyRegistration1Xml, malformedXml, missingOadrXmlSignatureXml, illegalBooleanValueXml } = require('./xml-requests');
+const { createPartyRegistration1 } = require('./js-requests');
+
+describe('Create Party Registration', function() {
+  describe('parse', function() {
+
+    let parsedResponse;
+
+    before(async () => {
+      parsedResponse = await parse(createPartyRegistration1Xml);
+    });
+
+    it ('successfully parses valid message', function() {
+      expect(parsedResponse.venId).to.eql('3f59d85fbdf3997dbeb1');
+      expect(parsedResponse.oadrProfileName).to.eql('2.0b');
+      expect(parsedResponse.oadrTransportName).to.eql('simpleHttp');
+      expect(parsedResponse.oadrReportOnly).to.eql(false);
+      expect(parsedResponse.oadrXmlSignature).to.eql(false);
+      expect(parsedResponse.oadrVenName).to.eql('OadrVen2b');
+      expect(parsedResponse.oadrHttpPullModel).to.eql(true);
+    });
+
+    it ('successfully parses serialized value', async function() {
+      const serialized = serialize(createPartyRegistration1);
+      const parsedResponse = await parse(serialized);
+      expect(parsedResponse).to.eql(createPartyRegistration1);
+    });
+
+    it ('throws Error on malformed input', async function() {
+      let lastError;
+      try {
+        await parse(malformedXml);
+      } catch (e) {
+        lastError = e;
+      }
+      expect(lastError).to.be.an('error');
+    });
+
+    it ('throws Error on missing required field', async function() {
+      let lastError;
+      try {
+        await parse(missingOadrXmlSignatureXml);
+      } catch (e) {
+        lastError = e;
+      }
+      expect(lastError).to.be.an('error');
+    });
+
+    it ('throws Error on illegal boolean value', async function() {
+      let lastError;
+      try {
+        await parse(illegalBooleanValueXml);
+      } catch (e) {
+        lastError = e;
+      }
+      expect(lastError).to.be.an('error');
+    });
+  });
+});

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 45 - 0
__tests__/unit/xml/created-party-registration.spec.js


+ 19 - 0
__tests__/unit/xml/js-requests.js

@@ -0,0 +1,19 @@
+'use strict';
+
+const createPartyRegistration1 = {
+  requestId: '2233',
+  registrationId: '3bd3c02dc6965c8b9240',
+  venId: '3f59d85fbdf3997dbeb1',
+  oadrProfileName: '2.0b',
+  oadrTransportName: 'simplehttp',
+  oadrReportOnly: false,
+  oadrXmlSignature: false,
+  oadrVenName: 'venName',
+  oadrHttpPullModel: true
+};
+
+module.exports = {
+  createPartyRegistration1
+};
+
+

+ 15 - 0
__tests__/unit/xml/js-responses.js

@@ -0,0 +1,15 @@
+'use strict';
+
+const createdPartyRegistration1 = {
+  responseCode: '200',
+  responseDescription: 'OK',
+  responseRequestId: 'rid',
+  registrationId: '3bd3c02dc6965c8b9240',
+  venId: '3f59d85fbdf3997dbeb1',
+  vtnId: 'VTN_ID1',
+  pollFreqDuration: 'PT10S'
+};
+
+module.exports = {
+  createdPartyRegistration1,
+};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 85 - 0
__tests__/unit/xml/xml-requests.js


+ 85 - 0
client/ven.js

@@ -0,0 +1,85 @@
+'use strict';
+
+const {
+  serialize: serializeCreatePartyRegistration,
+} = require('../xml/create-party-registration');
+const {
+  parse: parseCreatedPartyRegistration,
+} = require('../xml/created-party-registration');
+const axios = require('axios');
+const { escape } = require('querystring');
+
+class Ven {
+  constructor(
+    endpoint,
+    clientCertPem,
+    commonName,
+    venId,
+    venName,
+    registrationId,
+  ) {
+    if (!endpoint.endsWith('/')) endpoint += '/';
+    this.endpoint = endpoint;
+    this.clientCertHeader = escape(clientCertPem);
+    this.venId = venId;
+    this.registrationId = registrationId;
+    this.venName = venName;
+    this.commonName = commonName;
+  }
+
+  async register() {
+    const message = {
+      requestId: '2233',
+      registrationId: this.registrationId,
+      venId: this.venId,
+      oadrProfileName: '2.0b',
+      oadrTransportName: 'simplehttp',
+      oadrReportOnly: false,
+      oadrXmlSignature: false,
+      oadrVenName: this.venName,
+      oadrHttpPullModel: true,
+    };
+
+    const xml = serializeCreatePartyRegistration(message);
+
+    const config = {
+      headers: {
+        'Content-Type': 'application/xml',
+        // these next 2 headers are provided manually for now, simulating what nginx is doing.
+        SSL_CLIENT_S_DN_CN: this.commonName || 'no_client_cert',
+        SSL_CLIENT_CERTIFICATE: this.clientCertHeader,
+      },
+    };
+
+    // axios will automatically reject HTTP response codes outside the 200-299 range
+    const httpResponse = await axios.post(
+      `${this.endpoint}OpenADR2/Simple/2.0b/EiRegisterParty`,
+      xml,
+      config,
+    );
+    const registrationResponse = await parseCreatedPartyRegistration(
+      httpResponse.data,
+    );
+
+    // but OpenADR provides its own response code in the XML envelope, we need to check that
+    if (
+      registrationResponse.responseCode < 200 ||
+      registrationResponse.responseCode >= 300
+    ) {
+      throw new Error(
+        'Error during registration. ResponseCode=' +
+          registrationResponse.responseCode +
+          ', ResponseDescription=' +
+          registrationResponse.responseDescription,
+      );
+    }
+
+    // track registrationId for subsequent requests
+    this.registrationId = registrationResponse.registrationId;
+    return registrationResponse;
+  }
+}
+
+module.exports = {
+  Ven,
+};

+ 19 - 0
config/development.js

@@ -0,0 +1,19 @@
+'use strict';
+
+module.exports = {
+  port: process.env.PORT || 3000,
+  noAWS: process.env.NO_AWS,
+  loggerOptions: {
+    cache: {
+      grove: 'cloud',
+      env: 'dev',
+      domain: process.env.INSTANCE_ID || 'not provided',
+      module: 'cloud_oadr-vtn',
+      company: process.env.COMPANY || 'company_not_provided',
+    },
+  },
+  dbConfig: {
+    uri: process.env.DB_URL,
+  },
+  vtnId: 'NANTUM_VTN',
+};

+ 12 - 0
config/index.js

@@ -0,0 +1,12 @@
+'use strict';
+
+module.exports = (env => {
+  switch (env) {
+    case 'production':
+      return require('./production');
+    case 'test':
+      return require('./testing');
+    default:
+      return require('./development');
+  }
+})(process.env.NODE_ENV);

+ 18 - 0
config/production.js

@@ -0,0 +1,18 @@
+'use strict';
+
+module.exports = {
+  port: process.env.PORT || 3001,
+  loggerOptions: {
+    cache: {
+      grove: 'cloud',
+      env: 'prod',
+      domain: process.env.INSTANCE_ID || 'not provided',
+      module: 'cloud_oadr-vtn',
+      company: process.env.COMPANY || 'company_not_provided',
+    },
+  },
+  dbConfig: {
+    uri: process.env.DB_URL,
+  },
+  vtnId: 'NANTUM_VTN',
+};

+ 18 - 0
config/testing.js

@@ -0,0 +1,18 @@
+'use strict';
+
+module.exports = {
+  port: 3002,
+  loggerOptions: {
+    cache: {
+      grove: 'cloud',
+      env: 'test',
+      domain: process.env.INSTANCE_ID || 'not provided',
+      module: 'cloud_oadr-vtn',
+      company: process.env.COMPANY || 'company_not_provided',
+    },
+  },
+  dbConfig: {
+    uri: process.env.DB_URL,
+  },
+  vtnId: 'TEST_VTN',
+};

+ 15 - 0
db/_db.js

@@ -0,0 +1,15 @@
+'use strict';
+
+const Sequelize = require('sequelize');
+
+const {
+  dbConfig: { uri },
+} = require('../config');
+
+let sequelize;
+
+if (!sequelize) {
+  sequelize = new Sequelize(uri, { logging: false });
+}
+
+module.exports = sequelize;

+ 10 - 0
db/index.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const sequelize = require('./_db');
+
+const { Ven } = require('./models');
+
+module.exports = {
+  sequelize,
+  Ven,
+};

+ 7 - 0
db/models/index.js

@@ -0,0 +1,7 @@
+'use strict';
+
+const Ven = require('./ven');
+
+module.exports = {
+  Ven: Ven,
+};

+ 18 - 0
db/models/ven.js

@@ -0,0 +1,18 @@
+'use strict';
+
+const Sequelize = require('sequelize');
+const sequelize = require('../_db');
+
+const Ven = sequelize.define('Ven', {
+  common_name: {
+    type: Sequelize.STRING,
+    allowNull: false,
+  },
+  ven_id: {
+    type: Sequelize.STRING,
+    allowNull: false,
+  },
+  data: Sequelize.JSON,
+});
+
+module.exports = Ven;

+ 47 - 0
docker-compose.yml

@@ -0,0 +1,47 @@
+version: '3'
+
+services:
+
+  nginx:
+    container_name: nantum-vtn-nginx
+    ports:
+      - 443:443
+    image: nginx:latest
+    volumes:
+      - "./nginx.conf:/etc/nginx/conf.d/default.conf"
+      - "./ssl.crt:/etc/ssl/ssl.crt"
+      - "./ssl.key:/etc/ssl/ssl.key"
+      - "./clientssl.crt:/etc/ssl/clientssl.crt"
+    depends_on:
+      - nodejs
+    restart: unless-stopped
+
+  db:
+    container_name: nantum-vtn-db
+    expose:
+      - 5432
+#    ports:
+#      - 55432:5432
+    image: postgres:9.5
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+    environment:
+      POSTGRES_DB: vtn_test
+      POSTGRES_USER: vtn
+      POSTGRES_PASSWORD: vtn
+    restart: unless-stopped
+
+  nodejs:
+    container_name: nantum-vtn-nodejs
+    build: .
+    depends_on:
+      - db
+    restart: on-failure
+    environment:
+      NODE_ENV: development
+      DB_URL: postgres://vtn:vtn@nantum-vtn-db:5432/vtn_test
+      NO_AWS: 'true'
+      PORT: 8080
+
+volumes:
+  postgres_data:

+ 4 - 0
docker.npmrc

@@ -0,0 +1,4 @@
+@hw:registry=https://npm.nantum.org/
+@be:registry=https://npm.nantum.org/
+registry=https://registry.npmjs.org/
+//npm.nantum.org/:_authToken="${NPM_TOKEN}"

+ 8 - 0
docker_build.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+if [ -z "$NPM_TOKEN" ]; then
+  echo >&2 "Please set the NPM_TOKEN environment variable"
+  exit 1
+fi
+
+docker-compose build --build-arg NPM_TOKEN="${NPM_TOKEN}" nodejs

+ 3 - 0
docker_run_psql.sh

@@ -0,0 +1,3 @@
+#!/bin/bash
+
+docker-compose exec -u postgres db psql -U vtn vtn_test

+ 5 - 0
docker_run_tests.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -e
+
+docker-compose run --rm -e DB_URL=postgres://vtn:vtn@nantum-vtn-db:5432/vtn_test nodejs npm test

+ 12 - 0
index.js

@@ -0,0 +1,12 @@
+'use strict';
+
+const logger = require('./logger');
+const { sequelize } = require('./db');
+
+logger.info('Starting OADR VTN...');
+
+(async () => {
+  await sequelize.sync();
+  await require('./server').start();
+  logger.info('OADR VTN up and running');
+})();

+ 49 - 0
logger.js

@@ -0,0 +1,49 @@
+'use strict';
+
+const { loggerOptions, noAWS } = require('./config');
+
+function TestLogger() {}
+
+// intentionally disable logging during testing
+
+TestLogger.prototype.info = () => undefined;
+TestLogger.prototype.fatal = () => undefined;
+TestLogger.prototype.warn = () => undefined;
+
+// uncomment to see logs during tests
+
+// // eslint-disable-next-line no-console
+// TestLogger.prototype.info = () => console.log;
+// // eslint-disable-next-line no-console
+// TestLogger.prototype.fatal = () => console.error;
+// // eslint-disable-next-line no-console
+// TestLogger.prototype.warn = () => console.log;
+
+// console logging when running outside AWS
+
+function ConsoleLogger() {}
+
+// eslint-disable-next-line no-console
+ConsoleLogger.prototype.info = console.log;
+// eslint-disable-next-line no-console
+ConsoleLogger.prototype.fatal = console.error;
+// eslint-disable-next-line no-console
+ConsoleLogger.prototype.warn = console.log;
+
+let logger;
+
+switch (process.env.NODE_ENV) {
+  case 'production':
+  case 'development':
+    if (noAWS === 'true') {
+      logger = new ConsoleLogger();
+    } else {
+      logger = require('@hw/kinesis-logger')(loggerOptions);
+    }
+    break;
+  default:
+    logger = new TestLogger();
+}
+
+logger.info('Logger initialized');
+module.exports = logger;

+ 30 - 0
modules/certificate.js

@@ -0,0 +1,30 @@
+'use strict';
+
+const { unescape } = require('querystring');
+const { pki, md, asn1 } = require('node-forge');
+
+/*
+  escapedPemCertificate comes from nginx, it's the client cert in PEM format.
+  This function calculates the 10-byte fingerprint required by OpenADR.
+  Ref: OpenADR 2.0b protocol specification section 10.5.1
+*/
+function calculatePartialFingerprintOfEscapedPemCertificate(
+  escapedPemCertificate,
+) {
+  const pemCertificate = unescape(escapedPemCertificate);
+  const parsedCertificate = pki.certificateFromPem(pemCertificate);
+  const asn1Encoded = pki.certificateToAsn1(parsedCertificate);
+  const derEncoded = asn1.toDer(asn1Encoded).getBytes();
+  const fullFingerprintDelimited = md.sha256
+    .create()
+    .update(derEncoded)
+    .digest()
+    .toHex()
+    .match(/.{2}/g)
+    .join(':');
+  return fullFingerprintDelimited.slice(-29).toUpperCase();
+}
+
+module.exports = {
+  calculatePartialFingerprintOfEscapedPemCertificate,
+};

+ 43 - 0
nginx.conf

@@ -0,0 +1,43 @@
+map $ssl_client_s_dn $ssl_client_s_dn_cn {
+	default "no_client_cert";
+	~(^|,)CN=(?<CN>[^,]+) $CN;
+}
+
+server {
+    listen 443 ssl default_server;
+    server_name _;
+    root /dev/null;
+    
+    # next 3 lines force clients to provide a client cert, and ensure that the
+    # cert is trusted by the chain in clientssl.crt.
+    ssl_verify_client on;
+    ssl_client_certificate /etc/ssl/clientssl.crt;
+    ssl_verify_depth 2;
+
+    # Don't advertise
+    server_tokens off;
+
+    # Don't merge slashes
+    merge_slashes off;
+
+    location / {
+        proxy_set_header  X-Real-IP        $remote_addr;
+        proxy_set_header  X-Forwarded-For  $proxy_add_x_forwarded_for;
+        proxy_set_header  Host             $http_host;
+        proxy_set_header  X-Forwarded-Proto $scheme;
+        proxy_set_header  X-Forwarded-Ssl on;
+        proxy_set_header  X-Forwarded-Port $server_port;
+        proxy_set_header  X-Forwarded-Host $host;
+
+        # these next two headers are consumed by Nantum VTN to validate client cert
+        # CN vs. VEN Common Name, and client cert fingerprint vs. VenID.
+        proxy_set_header  SSL_CLIENT_S_DN_CN $ssl_client_s_dn_cn;
+        proxy_set_header  SSL_CLIENT_CERTIFICATE $ssl_client_escaped_cert;
+
+        proxy_redirect    off;
+        proxy_pass http://nantum-vtn-nodejs:8080;
+    }
+
+    ssl_certificate /etc/ssl/ssl.crt;
+    ssl_certificate_key /etc/ssl/ssl.key;
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 4389 - 0
package-lock.json


+ 41 - 0
package.json

@@ -0,0 +1,41 @@
+{
+  "name": "cloud_oadr-vtn",
+  "version": "1.0.0",
+  "description": "An OpenADR VTN",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "test": "npm run unit",
+    "unit": "NODE_ENV=test _mocha $(find __tests__ -name \"*.spec.js\")",
+    "lint": "eslint client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js && prettier --check client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js",
+    "fixlint": "eslint --fix client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js",
+    "fixprettier": "prettier --write client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://cdemoll@bitbucket.org/HWPD/cloud_oadr-vtn.git"
+  },
+  "dependencies": {
+    "axios": "^0.19.2",
+    "bluebird": "^3.7.2",
+    "body-parser": "^1.19.0",
+    "eslint": "^5.16.0",
+    "express": "^4.16.4",
+    "node-forge": "^0.9.1",
+    "pg": "^7.18.2",
+    "sequelize": "^5.21.6",
+    "uuid": "^7.0.3",
+    "xml2js": "^0.4.23",
+    "xmlbuilder2": "^2.1.1",
+    "@hw/kinesis-logger": "^1.0.3"
+  },
+  "devDependencies": {
+    "chai": "^4.2.0",
+    "mocha": "^7.1.1",
+    "prettier": "^1.19.1"
+  },
+  "keywords": [],
+  "author": "PD Data-Acquisition",
+  "license": "ISC",
+  "homepage": "https://bitbucket.org/HWPD/cloud_oadr-vtn#readme"
+}

+ 87 - 0
processes/registration.js

@@ -0,0 +1,87 @@
+'use strict';
+
+const { Ven } = require('../db');
+const { v4 } = require('uuid');
+const { vtnId } = require('../config');
+const logger = require('../logger');
+
+async function registerParty(
+  obj,
+  clientCertificateCn,
+  clientCertificateFingerprint,
+) {
+  logger.info(
+    'registerParty',
+    obj,
+    clientCertificateCn,
+    clientCertificateFingerprint,
+  );
+
+  const requestVenId = obj.venId;
+
+  if (!requestVenId) {
+    const error = new Error('No VenID in request.');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (requestVenId !== clientCertificateFingerprint) {
+    // as per certification item #512, venId MUST be case-sensitive
+    const error = new Error('VenID does not match certificate.');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (!clientCertificateCn) {
+    const error = new Error('Could not determine CN from client certificate.');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  let registrationId, venId;
+
+  const existingDbRecordByVenId = await Ven.findOne({
+    where: { ven_id: requestVenId },
+  });
+  const existingDbRecordByCommonName = await Ven.findOne({
+    where: { common_name: clientCertificateCn },
+  });
+
+  if (existingDbRecordByVenId) {
+    if (existingDbRecordByVenId.common_name !== clientCertificateCn) {
+      const error = new Error('Client certificate CN mismatch.');
+      error.responseCode = 452;
+      throw error;
+    }
+    registrationId = existingDbRecordByVenId.data.registrationId;
+    venId = existingDbRecordByVenId.ven_id;
+  } else if (existingDbRecordByCommonName) {
+    const error = new Error('Ven already exists with that CN.');
+    error.responseCode = 452;
+    throw error;
+  } else {
+    registrationId = v4().replace(/-/g, '');
+    venId = requestVenId;
+    const newVen = new Ven();
+    newVen.common_name = clientCertificateCn;
+    newVen.ven_id = requestVenId;
+    newVen.data = {
+      registrationId: registrationId,
+    };
+    await newVen.save();
+  }
+
+  return {
+    responseRequestId: obj.requestId || '',
+    responseCode: 200,
+    responseDescription: 'OK',
+    registrationId: registrationId,
+    venId: venId,
+    vtnId: vtnId,
+    pollFreqDuration: 'PT10S',
+  };
+}
+
+module.exports = {
+  registerParty,
+};

+ 37 - 0
server/controllers/register-party.js

@@ -0,0 +1,37 @@
+'use strict';
+
+const logger = require('../../logger');
+const { parse } = require('../../xml/create-party-registration');
+const { serialize } = require('../../xml/created-party-registration');
+
+const {
+  registerParty,
+} = require('../../processes/registration');
+
+exports.postController = async (req, res) => {
+  const xmlRequest = req.body;
+  let parsedRequest;
+  let xmlResponse;
+
+  try {
+    parsedRequest = await parse(xmlRequest);
+    const response = await registerParty(parsedRequest, req.clientCertificateCn, req.clientCertificateFingerprint);
+    xmlResponse = serialize(response);
+  } catch (e) {
+    logger.warn('Error occurred processing', parsedRequest, e);
+    const responseRequestId = (parsedRequest != null) ? parsedRequest.requestId : '';
+    xmlResponse = serialize({
+      responseCode: e.responseCode || '454',
+      responseDescription: e.message || 'Unknown error',
+      responseRequestId: responseRequestId || ''
+    });
+  }
+  res.set('Content-Type', 'application/xml');
+  res.send(xmlResponse);
+  res.end();
+};
+
+exports.postErrorHandler = (error, next) => {
+  logger.warn('Error in EiRegisterParty', { error });
+  next(error);
+};

+ 40 - 0
server/index.js

@@ -0,0 +1,40 @@
+'use strict';
+
+const express = require('express');
+const logger = require('../logger');
+const { port } = require('../config');
+
+const app = express();
+require('./middleware')(app);
+app.use('/', require('./routes'));
+// eslint-disable-next-line no-unused-vars
+app.use((err, req, res, next) => res.sendStatus(err.status || 500));
+
+let server;
+
+function start() {
+  return new Promise((resolve, reject) => {
+    if (server) {
+      resolve(app);
+      return;
+    }
+    server = app
+      .listen(port, () => {
+        logger.info(`Server running on port ${port}...`);
+        resolve(app);
+      })
+      .on('error', reject);
+  });
+}
+
+function stop() {
+  if (server) {
+    server.close();
+  }
+  server = undefined;
+}
+
+module.exports = {
+  start,
+  stop,
+};

+ 9 - 0
server/middleware/async-handler.js

@@ -0,0 +1,9 @@
+'use strict';
+
+module.exports = (fn, errorHandler) => {
+  return (req, res, next) => {
+    return Promise.resolve(fn(req, res, next)).catch(err =>
+      errorHandler(err, next),
+    );
+  };
+};

+ 24 - 0
server/middleware/certificate-parser.js

@@ -0,0 +1,24 @@
+'use strict';
+
+const { calculatePartialFingerprintOfEscapedPemCertificate } = require('../../modules/certificate');
+
+module.exports = async (req, res, next) => {
+  if (req.headers['ssl_client_s_dn_cn'] && req.headers['ssl_client_s_dn_cn'] !== 'no_client_cert') {
+    req.clientCertificateCn = req.headers['ssl_client_s_dn_cn'];
+  } else {
+    const err = new Error('Unauthorized');
+    err.status = 403;
+    return next(err);
+  }
+
+  if (req.headers['ssl_client_certificate']) {
+    const pemCertificateEscaped = req.headers['ssl_client_certificate'];
+    const fingerprint = calculatePartialFingerprintOfEscapedPemCertificate(pemCertificateEscaped);
+    req.clientCertificateFingerprint = fingerprint;
+  } else {
+    const err = new Error('Unauthorized');
+    err.status = 403;
+    return next(err);
+  }
+  return next();
+};

+ 9 - 0
server/middleware/index.js

@@ -0,0 +1,9 @@
+'use strict';
+
+const bodyParser = require('body-parser');
+const certificateParser = require('./certificate-parser');
+
+module.exports = app => {
+  app.use(bodyParser.text({ type: 'application/xml', limit: '1mb' }));
+  app.use(certificateParser);
+};

+ 9 - 0
server/routes/index.js

@@ -0,0 +1,9 @@
+'use strict';
+
+const router = require('express').Router();
+
+const oadrPrefix = '/OpenADR2/Simple/2.0b';
+
+router.use(`${oadrPrefix}/EiRegisterParty`, require('./register-party'));
+
+module.exports = router;

+ 13 - 0
server/routes/register-party.js

@@ -0,0 +1,13 @@
+'use strict';
+
+const router = require('express').Router();
+
+const asyncHandler = require('../middleware/async-handler');
+const {
+  postController,
+  postErrorHandler,
+} = require('../controllers/register-party');
+
+router.post('/', asyncHandler(postController, postErrorHandler));
+
+module.exports = router;

+ 128 - 0
xml/create-party-registration.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const { parseXML, childAttr, boolean, required } = require('./parser');
+const { create, fragment } = require('xmlbuilder2');
+
+const oadrPayloadNs = 'http://www.w3.org/2000/09/xmldsig#';
+const oadrNs = 'http://openadr.org/oadr-2.0b/2012/07';
+const energyInteropNs = 'http://docs.oasis-open.org/ns/energyinterop/201110';
+const energyInteropPayloadsNs =
+  'http://docs.oasis-open.org/ns/energyinterop/201110/payloads';
+const calendarNs = 'urn:ietf:params:xml:ns:icalendar-2.0';
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'][
+      'oadrCreatePartyRegistration'
+    ][0]['$$'];
+
+  const result = {
+    requestId: required(childAttr(o, 'requestID'), 'requestID'),
+    oadrProfileName: required(
+      childAttr(o, 'oadrProfileName'),
+      'oadrProfileName',
+    ),
+    oadrTransportName: required(
+      childAttr(o, 'oadrTransportName'),
+      'oadrTransportName',
+    ),
+    oadrReportOnly: required(
+      boolean(childAttr(o, 'oadrReportOnly')),
+      'oadrReportOnly',
+    ),
+    oadrXmlSignature: required(
+      boolean(childAttr(o, 'oadrXmlSignature')),
+      'oadrXmlSignature',
+    ),
+  };
+
+  const registrationId = childAttr(o, 'registrationID');
+  const venId = childAttr(o, 'venID');
+  const oadrTransportAddress = childAttr(o, 'oadrTransportAddress');
+  const oadrVenName = childAttr(o, 'oadrVenName');
+  const oadrHttpPullModel = boolean(childAttr(o, 'oadrHttpPullModel'));
+
+  if (registrationId != null) result.registrationId = registrationId;
+  if (venId != null) result.venId = venId;
+  if (oadrTransportAddress != null)
+    result.oadrTransportAddress = oadrTransportAddress;
+  if (oadrVenName != null) result.oadrVenName = oadrVenName;
+  if (oadrHttpPullModel != null) result.oadrHttpPullModel = oadrHttpPullModel;
+
+  return result;
+}
+
+function serialize(obj) {
+  const registrationId =
+    obj.registrationId != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:registrationID')
+          .txt(obj.registrationId)
+      : fragment();
+  const venId =
+    obj.venId != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:venID')
+          .txt(obj.venId)
+      : fragment();
+  const oadrTransportAddress =
+    obj.oadrTransportAddress != null
+      ? fragment()
+          .ele(oadrNs, 'oadr2b:oadrTransportAddress')
+          .txt(obj.oadrTransportAddress)
+      : fragment();
+  const oadrVenName =
+    obj.oadrVenName != null
+      ? fragment()
+          .ele(oadrNs, 'oadr2b:oadrVenName')
+          .txt(obj.oadrVenName)
+      : fragment();
+  const oadrHttpPullModel =
+    obj.oadrHttpPullModel != null
+      ? fragment()
+          .ele(oadrNs, 'oadr2b:oadrHttpPullModel')
+          .txt(obj.oadrHttpPullModel)
+      : fragment();
+
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+      cal: calendarNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrCreatePartyRegistration')
+    .att(energyInteropNs, 'ei:schemaVersion', '2.0b')
+    .ele('@pyld', 'pyld:requestID')
+    .txt(obj.requestId)
+    .up()
+    .import(venId)
+    .import(registrationId)
+    .ele('oadr2b:oadrProfileName')
+    .txt(obj.oadrProfileName)
+    .up()
+    .ele('oadr2b:oadrTransportName')
+    .txt(obj.oadrTransportName)
+    .up()
+    .import(oadrTransportAddress)
+    .ele('oadr2b:oadrReportOnly')
+    .txt(obj.oadrReportOnly)
+    .up()
+    .ele('oadr2b:oadrXmlSignature')
+    .txt(obj.oadrXmlSignature)
+    .up()
+    .import(oadrVenName)
+    .import(oadrHttpPullModel)
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 172 - 0
xml/created-party-registration.js

@@ -0,0 +1,172 @@
+/* eslint-disable indent */
+'use strict';
+
+const { parseXML, childAttr, required } = require('./parser');
+const { create, fragment } = require('xmlbuilder2');
+
+const oadrPayloadNs = 'http://www.w3.org/2000/09/xmldsig#';
+const oadrNs = 'http://openadr.org/oadr-2.0b/2012/07';
+const energyInteropNs = 'http://docs.oasis-open.org/ns/energyinterop/201110';
+const energyInteropPayloadsNs =
+  'http://docs.oasis-open.org/ns/energyinterop/201110/payloads';
+const calendarNs = 'urn:ietf:params:xml:ns:icalendar-2.0';
+
+function parseEiResponse(response) {
+  return {
+    code: required(childAttr(response, 'responseCode'), 'responseCode'),
+    description: childAttr(response, 'responseDescription'),
+    requestId: required(childAttr(response, 'requestID'), 'requestID'),
+  };
+}
+
+function parseDuration(response) {
+  return required(childAttr(response, 'duration'), 'duration');
+}
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'][
+      'oadrCreatedPartyRegistration'
+    ][0]['$$'];
+
+  const { code, description, requestId } = parseEiResponse(
+    o['eiResponse'][0]['$$'],
+  );
+
+  const result = {
+    responseCode: code,
+    responseDescription: description,
+    responseRequestId: requestId,
+  };
+
+  if (code < 200 || code >= 300) {
+    return result;
+  }
+
+  const registrationId = childAttr(o, 'registrationID');
+  if (registrationId != null) result.registrationId = registrationId;
+
+  const venId = childAttr(o, 'venID');
+  if (venId != null) result.venId = venId;
+
+  const vtnId = childAttr(o, 'vtnID');
+  if (vtnId != null) result.vtnId = vtnId;
+
+  const oadrRequestedOadrPollFreq = childAttr(o, 'oadrRequestedOadrPollFreq');
+  if (oadrRequestedOadrPollFreq != null) {
+    const oadrRequestedOadrPollFreqDuration = parseDuration(
+      oadrRequestedOadrPollFreq['$$'],
+    );
+    if (oadrRequestedOadrPollFreqDuration != null)
+      result.pollFreqDuration = oadrRequestedOadrPollFreqDuration;
+  }
+
+  return result;
+}
+
+function serializeEiResponse(code, description, requestId) {
+  const descriptionFrag =
+    description != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:responseDescription')
+          .txt(description)
+      : fragment();
+  return fragment()
+    .ele(energyInteropNs, 'ei:responseCode')
+    .txt(code)
+    .up()
+    .import(descriptionFrag)
+    .ele(energyInteropPayloadsNs, 'pyld:requestID')
+    .txt(requestId)
+    .up();
+}
+
+function serializeDuration(duration) {
+  return duration != null
+    ? fragment()
+        .ele(calendarNs, 'cal:duration')
+        .txt(duration)
+    : fragment();
+}
+
+function validate(obj) {
+  if (!obj.responseCode) {
+    throw new Error('Missing responseCode');
+  }
+  if (!obj.responseRequestId) {
+    throw new Error('Missing responseRequestId');
+  }
+}
+
+function serialize(obj) {
+  validate(obj);
+
+  const registrationId =
+    obj.registrationId != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:registrationID')
+          .txt(obj.registrationId)
+      : fragment();
+  const venId =
+    obj.venId != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:venID')
+          .txt(obj.venId)
+      : fragment();
+  const vtnId =
+    obj.vtnId != null
+      ? fragment()
+          .ele(energyInteropNs, 'ei:vtnID')
+          .txt(obj.vtnId)
+      : fragment();
+
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+      cal: calendarNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrCreatedPartyRegistration')
+    .att('@ei', 'ei:schemaVersion', '2.0b')
+    .ele('@ei', 'ei:eiResponse')
+    .import(
+      serializeEiResponse(
+        obj.responseCode,
+        obj.responseDescription,
+        obj.responseRequestId,
+      ),
+    )
+    .up()
+    .import(registrationId)
+    .import(venId)
+    .import(vtnId)
+    .ele('oadr2b:oadrProfiles')
+    .ele('oadr2b:oadrProfile')
+    .ele('oadr2b:oadrProfileName')
+    .txt('2.0b')
+    .up()
+    .ele('oadr2b:oadrTransports')
+    .ele('oadr2b:oadrTransport')
+    .ele('oadr2b:oadrTransportName')
+    .txt('simpleHttp')
+    .up()
+    .up()
+    .up()
+    .up()
+    .up()
+    .ele('oadr2b:oadrRequestedOadrPollFreq')
+    .import(serializeDuration(obj.pollFreqDuration))
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 62 - 0
xml/parser.js

@@ -0,0 +1,62 @@
+'use strict';
+
+const xml2js = require('xml2js');
+const stripNS = xml2js.processors.stripPrefix;
+const parser = new xml2js.Parser({
+  tagNameProcessors: [stripNS],
+  // xmlns: true,
+  explicitChildren: true,
+});
+
+const parseXML = input => parser.parseStringPromise(input);
+
+function childAttr(obj, key, errorCode = 452) {
+  const value = obj[key];
+  if (value === undefined || value.length === 0) {
+    return undefined;
+  }
+
+  if (value.length !== 1) {
+    const error = new Error(`Invalid attribute: ${key}`);
+    error.errorCode = errorCode;
+    throw error;
+  }
+
+  if (value[0]['$'] && value[0]['_']) {
+    return value[0]['_'];
+  }
+
+  return value[0];
+}
+
+function boolean(input, errorCode = 452) {
+  const trimmed = (input || '').toLowerCase().trim();
+
+  if (trimmed === '') {
+    return undefined;
+  } else if (trimmed === 'true' || trimmed === '1') {
+    return true;
+  } else if (trimmed === 'false' || trimmed === '0') {
+    return false;
+  }
+
+  const error = new Error(`Not a boolean: ${input}`);
+  error.errorCode = errorCode;
+  throw error;
+}
+
+function required(input, key, errorCode = 452) {
+  if (input == null) {
+    const error = new Error(`Missing required value: ${key}`);
+    error.errorCode = errorCode;
+    throw error;
+  }
+  return input;
+}
+
+module.exports = {
+  parseXML,
+  childAttr,
+  boolean,
+  required,
+};