@cap-js/ord 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -198,4 +198,4 @@
198
198
  distributed under the License is distributed on an "AS IS" BASIS,
199
199
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
200
  See the License for the specific language governing permissions and
201
- limitations under the License.
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,18 +1,16 @@
1
- [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/ord)](https://api.reuse.software/info/github.com/cap-js/cds-plugin-for-ord)
1
+ [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/ord)](https://api.reuse.software/info/github.com/cap-js/ord)
2
2
 
3
3
  # CDS Plugin for ORD
4
4
 
5
5
  ## About this project
6
6
 
7
- This plugin adds support for the [Open Resource Discovery](https://sap.github.io/open-resource-discovery/) (ORD) protocol for CAP based applications.
7
+ This plugin adds support for the [Open Resource Discovery](https://open-resource-discovery.github.io/specification/) (ORD) protocol for CAP based applications.
8
8
  When you add the ORD plugin, your application gains a single entry point, which allows to discover and gather machine-readable information or metadata about the application.
9
9
  You can use this information to construct a static metadata catalog or to perform a detailed runtime inspection of your actual system instances / system landscapes.
10
10
 
11
- For more information, have a look at the [Open Resource Discovery](https://sap.github.io/open-resource-discovery/) page.
11
+ For more information, have a look at the [Open Resource Discovery](https://open-resource-discovery.github.io/specification/) page.
12
12
 
13
- > ⚠ By installing this plugin, the metadata describing your CAP application will be made openly accessible.
14
- >
15
- > If you have a need to protect your metadata, please refrain from installing this plugin until we support metadata protection (planned).
13
+ > ⚠ By installing this plugin, the metadata describing your CAP application will be made openly accessible. If you want to secure your CAP application's metadata, configure `basic` authentication by setting the environment variables or updating the `.cdsrc.json` file. The plugin prioritizes environment variables, then checks `.cdsrc.json`. If neither is configured, metadata remains publicly accessible.
16
14
 
17
15
  ## Requirements and Setup
18
16
 
@@ -22,6 +20,84 @@ For more information, have a look at the [Open Resource Discovery](https://sap.g
22
20
  npm install @cap-js/ord
23
21
  ```
24
22
 
23
+ > Note: `@cap-js/openapi` and `@cap-js/asyncapi` packages have been migrated from peerDependencies to dependencies in `package.json`. As a result, using globally installed packages may lead to conflicts. If conflicts arises do `npm uninstall -g @cap-js/openapi @cap-js/asyncapi` and then `npm install` in your project directory.
24
+
25
+ ### Authentication
26
+
27
+ To enforce authentication in the ORD Plugin, set the following environment variables:
28
+
29
+ - `ORD_AUTH_TYPE`: Specifies the authentication types.
30
+ - `BASIC_AUTH`: Contains credentials for `basic` authentication.
31
+
32
+ If `ORD_AUTH_TYPE` is not set, the application starts without authentication. This variable accepts `open` and `basic` (UCL-mTLS is also planned).
33
+
34
+ > Note: `open` cannot be combined with `basic` or any other (future) authentication types.
35
+
36
+ #### Open
37
+
38
+ The `open` authentication type bypasses authentication checks.
39
+
40
+ #### Basic Authentication
41
+
42
+ The server supports Basic Authentication through an environment variable that contains a JSON string mapping usernames to bcrypt-hashed passwords:
43
+
44
+ ```bash
45
+ BASIC_AUTH='{"admin":"***"}'
46
+ ```
47
+
48
+ Alternatively, configure authentication in `.cdsrc.json`:
49
+
50
+ ```json
51
+ "authentication": {
52
+ "types": ["basic"],
53
+ "credentials": {
54
+ "admin": "***"
55
+ }
56
+ }
57
+ ```
58
+
59
+ To generate bcrypt hashes, use the [htpasswd](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) utility:
60
+
61
+ ```bash
62
+ htpasswd -Bnb <user> <password>
63
+ ```
64
+
65
+ This will output something like `admin:$2y$05$...` - use only the hash part (starting with `$2y$`) in your `BASIC_AUTH` JSON.
66
+
67
+ > [!IMPORTANT]
68
+ > Make sure to use strong passwords and handle the BASIC_AUTH environment variable securely. Never commit real credentials or .env files to version control.
69
+
70
+ <details>
71
+ <summary>Using htpasswd in your environment</summary>
72
+
73
+ - **Platform independent**:
74
+
75
+ > Prerequisite is to have [NodeJS](https://nodejs.org/en) installed on the machine.
76
+
77
+ ```bash
78
+ npm install -g htpasswd
79
+ ```
80
+
81
+ After installing package globally, command `htpasswd` should be available in the Terminal.
82
+
83
+ - **macOS**:
84
+
85
+ Installation of any additional packages is not required. Utility `htpasswd` is available in Terminal by default.
86
+
87
+ - **Linux**:
88
+
89
+ Install apache2-utils package:
90
+
91
+ ```bash
92
+ # Debian/Ubuntu
93
+ sudo apt-get install apache2-utils
94
+
95
+ # RHEL/CentOS
96
+ sudo yum install httpd-tools
97
+ ```
98
+
99
+ </details>
100
+
25
101
  ### Usage
26
102
 
27
103
  #### Programmatic API
@@ -32,12 +108,26 @@ require("@cap-js/ord");
32
108
  ```
33
109
 
34
110
  ```js
35
- const csn = await cds.load(cds.env.folders.srv);
111
+ const csn = cds.context?.model || cds.model;
36
112
  const ord = cds.compile.to.ord(csn);
37
113
  ```
38
114
 
39
115
  #### Command Line
40
116
 
117
+ Build all ord related documents, including ordDocument and services resources files:
118
+
119
+ ```sh
120
+ cds build --for ord
121
+
122
+ # By default, it will be generated in /gen/ord dir, e.g.:
123
+ # done > wrote output to:
124
+ # gen/ord/ord-document.json
125
+ # gen/ord/sap.sample:apiResource:AdminService:v1/AdminService.edmx
126
+ # gen/ord/sap.sample:apiResource:AdminService:v1/AdminService.oas3.json
127
+ ```
128
+
129
+ Only compile ord document:
130
+
41
131
  ```sh
42
132
  cds compile <path to srv folder> --to ord [-o] [destinationFilePath]
43
133
  ```
@@ -47,7 +137,7 @@ cds compile <path to srv folder> --to ord [-o] [destinationFilePath]
47
137
  #### ORD Endpoints
48
138
 
49
139
  1. Run `cds watch` in the application's root.
50
- 2. Check the following relative paths for ORD information - `/.well-known/open-resource-discovery` , `/open-resource-discovery/v1/documents/1`.
140
+ 2. Check the following relative paths for ORD information - `/.well-known/open-resource-discovery` , `/ord/v1/documents/ord-document`.
51
141
 
52
142
  <img width="1300" alt="Sample Application Demo" style="border-radius:0.5rem;" src="./asset/etc/ordEndpoint.gif">
53
143
 
package/cds-plugin.js CHANGED
@@ -1,17 +1,26 @@
1
- require("./lib/plugin");
2
1
  const cds = require("@sap/cds");
2
+ const { getAuthConfig } = require("./lib/authentication");
3
+
4
+ if (cds.cli.command === "build") {
5
+ cds.build?.register?.("ord", require("./lib/build"));
6
+ }
7
+
8
+ // load auth config before any service is started
9
+ cds.on("bootstrap", async () => {
10
+ getAuthConfig();
11
+ });
3
12
 
4
13
  function _lazyRegisterCompileTarget() {
5
- const ord = require("./lib/index").ord;
6
- Object.defineProperty(this, "ord", { ord });
7
- return ord;
14
+ const ord = require("./lib/index").ord;
15
+ Object.defineProperty(this, "ord", { ord });
16
+ return ord;
8
17
  }
9
18
 
10
19
  const registerORDCompileTarget = () => {
11
- Object.defineProperty(cds.compile.to, "ord", {
12
- get: _lazyRegisterCompileTarget,
13
- configurable: true,
14
- });
20
+ Object.defineProperty(cds.compile.to, "ord", {
21
+ get: _lazyRegisterCompileTarget,
22
+ configurable: true,
23
+ });
15
24
  };
16
25
 
17
26
  registerORDCompileTarget();
@@ -2,7 +2,7 @@
2
2
  "openResourceDiscoveryV1": {
3
3
  "documents": [
4
4
  {
5
- "url": "/open-resource-discovery/v1/documents/1",
5
+ "url": "/ord/v1/documents/ord-document",
6
6
  "accessStrategies": [
7
7
  {
8
8
  "type": "open"
@@ -11,4 +11,4 @@
11
11
  }
12
12
  ]
13
13
  }
14
- }
14
+ }
@@ -0,0 +1,153 @@
1
+ const cds = require("@sap/cds");
2
+ const { AUTHENTICATION_TYPE, BASIC_AUTH_HEADER_KEY, AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP } = require("./constants");
3
+ const { Logger } = require("./logger");
4
+ const bcrypt = require("bcrypt");
5
+
6
+ /**
7
+ * Compares a plain text password with a hashed password
8
+ * @param {string} password Plain text password to check
9
+ * @param {string} hashedPassword Hashed password to compare against
10
+ * @returns {Promise<boolean>} Promise resolving to true if passwords match, false otherwise
11
+ */
12
+ async function _comparePassword(password, hashedPassword) {
13
+ if (!password || !hashedPassword) {
14
+ throw new Error("Password and hashed password are required");
15
+ }
16
+ return await bcrypt.compare(password, hashedPassword.replace(/^\$2y/, "$2a"));
17
+ }
18
+
19
+ /**
20
+ * Validates if a string is a bcrypt hash
21
+ * @param {string} hash String to validate
22
+ * @returns {boolean} boolean indicating if the string is a bcrypt hash
23
+ */
24
+ function _isBcryptHash(hash) {
25
+ return /^\$2[ayb]\$\d{2}\$[A-Za-z0-9./]{53}$/.test(hash);
26
+ }
27
+
28
+ /**
29
+ * Create authentication configuration based on data given in the environment variables.
30
+ * @returns {Object} Authentication configuration object or default configuration object as a fallback.
31
+ */
32
+ function createAuthConfig() {
33
+ const defaultAuthConfig = {
34
+ types: [AUTHENTICATION_TYPE.Open],
35
+ accessStrategies: [{ type: AUTHENTICATION_TYPE.Open }],
36
+ };
37
+
38
+ try {
39
+ const authConfig = {};
40
+
41
+ authConfig.types = process.env.ORD_AUTH_TYPE
42
+ ? [...new Set(JSON.parse(process.env.ORD_AUTH_TYPE))]
43
+ : [...new Set(cds.env.authentication?.types)];
44
+
45
+ if (!authConfig.types || authConfig.types.length === 0) {
46
+ Logger.error("createAuthConfig:", 'No authorization type is provided. Defaulting to "Open" authentication');
47
+ return defaultAuthConfig;
48
+ }
49
+
50
+ if (authConfig.types.some((authType) => !Object.values(AUTHENTICATION_TYPE).includes(authType))) {
51
+ return Object.assign(defaultAuthConfig, { error: "Invalid authentication type" });
52
+ }
53
+
54
+ if (
55
+ authConfig.types.includes(AUTHENTICATION_TYPE.Open) &&
56
+ authConfig.types.includes(AUTHENTICATION_TYPE.Basic)
57
+ ) {
58
+ return Object.assign(defaultAuthConfig, {
59
+ error: "Open authentication cannot be combined with any other authentication type",
60
+ });
61
+ }
62
+
63
+ if (authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
64
+ const credentials = process.env.BASIC_AUTH
65
+ ? JSON.parse(process.env.BASIC_AUTH)
66
+ : cds.env.authentication.credentials;
67
+
68
+ // Check all passwords in credentials map
69
+ for (const [username, password] of Object.entries(credentials)) {
70
+ if (!_isBcryptHash(password)) {
71
+ Logger.error("createAuthConfig:", `Password for user "${username}" must be a bcrypt hash`);
72
+ return Object.assign(defaultAuthConfig, { error: "All passwords must be bcrypt hashes" });
73
+ }
74
+ }
75
+ // Store the complete credentials map
76
+ authConfig.credentials = credentials;
77
+ }
78
+ authConfig.accessStrategies = authConfig.types.map((type) => ({
79
+ type: AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP[type],
80
+ }));
81
+ return authConfig;
82
+ } catch (error) {
83
+ return Object.assign(defaultAuthConfig, { error: error.message });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Validate, store authentication configuration in cds.context if undefined, and return the context.
89
+ */
90
+ function getAuthConfig() {
91
+ if (cds.context?.authConfig) return cds.context?.authConfig;
92
+
93
+ const authConfig = createAuthConfig();
94
+
95
+ if (authConfig.error) {
96
+ Logger.error("Authentication configuration error: " + authConfig.error);
97
+ throw new Error("Invalid authentication configuration");
98
+ }
99
+
100
+ // set the context
101
+ cds.context = {
102
+ authConfig,
103
+ };
104
+ return cds.context?.authConfig;
105
+ }
106
+
107
+ /**
108
+ * Middleware to authenticate the request based on the authentication configuration.
109
+ */
110
+ async function authenticate(req, res, next) {
111
+ const authConfig = cds.context.authConfig;
112
+
113
+ if (authConfig.types.includes(AUTHENTICATION_TYPE.Open)) {
114
+ res.status(200);
115
+ return next();
116
+ }
117
+
118
+ if (
119
+ !Object.keys(req.headers).includes(BASIC_AUTH_HEADER_KEY) &&
120
+ authConfig.types.includes(AUTHENTICATION_TYPE.Basic)
121
+ ) {
122
+ return res.status(401).setHeader("WWW-Authenticate", 'Basic realm="401"').send("Authentication required.");
123
+ }
124
+
125
+ if (req.headers[BASIC_AUTH_HEADER_KEY] && authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
126
+ const authHeader = req.headers[BASIC_AUTH_HEADER_KEY];
127
+
128
+ if (!authHeader.startsWith("Basic ")) {
129
+ return res.status(401).send("Invalid authentication type");
130
+ }
131
+
132
+ const [username, password] = Buffer.from(authHeader.split(" ")[1], "base64").toString().split(":");
133
+ const credentials = authConfig.credentials;
134
+ const storedPassword = credentials[username];
135
+
136
+ if (storedPassword && (await _comparePassword(password, storedPassword))) {
137
+ res.status(200);
138
+ return next();
139
+ } else {
140
+ return res.status(401).send("Invalid credentials");
141
+ }
142
+ }
143
+
144
+ // In other cases, the request is unauthorized.
145
+ // TODO: Add support for UCL-mTLS authorization.
146
+ return res.status(401).send("Not authorized");
147
+ }
148
+
149
+ module.exports = {
150
+ authenticate,
151
+ createAuthConfig,
152
+ getAuthConfig,
153
+ };
package/lib/build.js ADDED
@@ -0,0 +1,53 @@
1
+ const cds = require("@sap/cds");
2
+ const cds_dk = require("@sap/cds-dk");
3
+ const { path } = cds.utils;
4
+ const { ord, getMetadata } = require("./index");
5
+ const { BUILD_DEFAULT_PATH, ORD_SERVICE_NAME, ORD_DOCUMENT_FILE_NAME } = require("./constants");
6
+
7
+ module.exports = class OrdBuildPlugin extends cds_dk.build.Plugin {
8
+ static taskDefaults = { src: cds.env.folders.srv };
9
+
10
+ init() {
11
+ this.task.dest = path.join(cds.root, BUILD_DEFAULT_PATH);
12
+ }
13
+
14
+ async _writeResourcesFiles(resObj, model, promises) {
15
+ for (const resource of resObj) {
16
+ if (resource.ordId.includes(ORD_SERVICE_NAME) || !resource.resourceDefinitions) {
17
+ continue;
18
+ }
19
+
20
+ const subDir = path.join(this.task.dest, resource.ordId);
21
+
22
+ for (const resourceDefinition of resource.resourceDefinitions) {
23
+ const url = resourceDefinition.url;
24
+ const fileName = url.split("/").pop();
25
+ try {
26
+ const { _, response } = await getMetadata(url, model); // eslint-disable-line no-unused-vars
27
+ promises.push(
28
+ this.write(response)
29
+ .to(path.join(subDir, fileName))
30
+ .catch((err) => {
31
+ console.log("Error", `Failed to write file ${fileName}: ${err.message}`);
32
+ }),
33
+ );
34
+ } catch (error) {
35
+ console.log("Error", `Failed to get metadata for ${url}: ${error.message}`);
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ async build() {
42
+ const model = await this.model();
43
+ const ordDocument = ord(model);
44
+
45
+ const promises = [];
46
+ promises.push(this.write(ordDocument).to(ORD_DOCUMENT_FILE_NAME));
47
+
48
+ await this._writeResourcesFiles(ordDocument.apiResources, model, promises);
49
+ await this._writeResourcesFiles(ordDocument.eventResources, model, promises);
50
+
51
+ return Promise.all(promises);
52
+ }
53
+ };
package/lib/constants.js CHANGED
@@ -1,39 +1,115 @@
1
+ const AUTHENTICATION_TYPE = Object.freeze({
2
+ Open: "open",
3
+ Basic: "basic",
4
+ });
5
+
6
+ const BASIC_AUTH_HEADER_KEY = "authorization";
7
+
8
+ const BUILD_DEFAULT_PATH = "gen/ord";
9
+
10
+ const BLOCKED_SERVICE_NAME = Object.freeze({
11
+ MTXServices: "cds.xt.MTXServices",
12
+ });
13
+
14
+ const ORD_ACCESS_STRATEGY = Object.freeze({
15
+ Open: "open",
16
+ Basic: "sap.businesshub:basic-auth:v1",
17
+ });
18
+
19
+ const AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP = Object.freeze({
20
+ [AUTHENTICATION_TYPE.Open]: ORD_ACCESS_STRATEGY.Open,
21
+ [AUTHENTICATION_TYPE.Basic]: ORD_ACCESS_STRATEGY.Basic,
22
+ });
23
+
24
+ const CDS_ELEMENT_KIND = Object.freeze({
25
+ action: "action",
26
+ annotation: "annotation",
27
+ context: "context",
28
+ function: "function",
29
+ entity: "entity",
30
+ event: "event",
31
+ service: "service",
32
+ type: "type",
33
+ });
34
+
1
35
  const COMPILER_TYPES = Object.freeze({
2
36
  oas3: "oas3",
3
37
  asyncapi2: "asyncapi2",
4
38
  edmx: "edmx",
39
+ csn: "csn",
5
40
  });
6
41
 
7
42
  const CONTENT_MERGE_KEY = "ordId";
8
43
 
44
+ const DATA_PRODUCT_ANNOTATION = "@DataIntegration.dataProduct.type";
45
+
46
+ const DATA_PRODUCT_TYPE = Object.freeze({
47
+ primary: "primary",
48
+ });
49
+
9
50
  const DESCRIPTION_PREFIX = "Description for ";
10
51
 
52
+ const ENTITY_RELATIONSHIP_ANNOTATION = "@EntityRelationship.entityType";
53
+
54
+ const LEVEL = Object.freeze({
55
+ aggregate: "aggregate",
56
+ rootEntity: "root-entity",
57
+ subEntity: "sub-entity",
58
+ });
59
+
11
60
  const OPEN_RESOURCE_DISCOVERY_VERSION = "1.9";
12
61
 
13
62
  const ORD_EXTENSIONS_PREFIX = "@ORD.Extensions.";
14
63
 
64
+ const ORD_ODM_ENTITY_NAME_ANNOTATION = "@ODM.entityName";
65
+
66
+ const ORD_EXISTING_PRODUCT_PROPERTY = "existingProductORDId";
67
+
15
68
  const ORD_RESOURCE_TYPE = Object.freeze({
16
- service: "service",
17
- entity: "entity",
18
- event: "event",
19
69
  api: "api",
70
+ event: "event",
71
+ integrationDependency: "integrationDependency",
72
+ entityType: "entityType",
20
73
  });
21
74
 
75
+ const ORD_SERVICE_NAME = "OpenResourceDiscoveryService";
76
+
77
+ const ORD_DOCUMENT_FILE_NAME = "ord-document.json";
78
+
22
79
  const RESOURCE_VISIBILITY = Object.freeze({
23
80
  public: "public",
24
81
  internal: "internal",
25
82
  private: "private",
26
83
  });
27
84
 
28
- const SHORT_DESCRIPTION_PREFIX = "Short description for ";
85
+ const SHORT_DESCRIPTION_PREFIX = "Short description of ";
86
+
87
+ const SEM_VERSION_REGEX =
88
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
29
89
 
30
90
  module.exports = {
91
+ AUTHENTICATION_TYPE,
92
+ AUTH_TYPE_ORD_ACCESS_STRATEGY_MAP,
93
+ BASIC_AUTH_HEADER_KEY,
94
+ BUILD_DEFAULT_PATH,
95
+ BLOCKED_SERVICE_NAME,
96
+ CDS_ELEMENT_KIND,
31
97
  COMPILER_TYPES,
32
98
  CONTENT_MERGE_KEY,
99
+ DATA_PRODUCT_ANNOTATION,
100
+ DATA_PRODUCT_TYPE,
33
101
  DESCRIPTION_PREFIX,
102
+ ENTITY_RELATIONSHIP_ANNOTATION,
103
+ LEVEL,
34
104
  OPEN_RESOURCE_DISCOVERY_VERSION,
105
+ ORD_ACCESS_STRATEGY,
106
+ ORD_DOCUMENT_FILE_NAME,
35
107
  ORD_EXTENSIONS_PREFIX,
108
+ ORD_ODM_ENTITY_NAME_ANNOTATION,
109
+ ORD_EXISTING_PRODUCT_PROPERTY,
36
110
  ORD_RESOURCE_TYPE,
111
+ ORD_SERVICE_NAME,
37
112
  RESOURCE_VISIBILITY,
38
113
  SHORT_DESCRIPTION_PREFIX,
114
+ SEM_VERSION_REGEX,
39
115
  };
package/lib/date.js CHANGED
@@ -1,22 +1,17 @@
1
- function getRFC3339Date(includeOffset = true) {
1
+ function getRFC3339Date() {
2
2
  const now = new Date();
3
3
  const year = now.getUTCFullYear();
4
- const month = String(now.getUTCMonth() + 1).padStart(2, '0');
5
- const day = String(now.getUTCDate()).padStart(2, '0');
6
- const hours = String(now.getUTCHours()).padStart(2, '0');
7
- const minutes = String(now.getUTCMinutes()).padStart(2, '0');
8
- const seconds = String(now.getUTCSeconds()).padStart(2, '0');
9
-
10
- if (includeOffset) {
11
- const offsetHours = '01';
12
- const offsetMinutes = '00';
13
- const offsetSign = '+';
14
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
15
- } else {
16
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
17
- }
4
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
5
+ const day = String(now.getUTCDate()).padStart(2, "0");
6
+ const hours = String(now.getUTCHours()).padStart(2, "0");
7
+ const minutes = String(now.getUTCMinutes()).padStart(2, "0");
8
+ const seconds = String(now.getUTCSeconds()).padStart(2, "0");
9
+ const offsetHours = "01";
10
+ const offsetMinutes = "00";
11
+ const offsetSign = "+";
12
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
18
13
  }
19
14
 
20
15
  module.exports = {
21
- getRFC3339Date
16
+ getRFC3339Date,
22
17
  };