@boxyhq/saml-jackson 0.2.3-beta.207 → 0.2.3-beta.219

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.
Files changed (46) hide show
  1. package/.eslintrc.js +13 -0
  2. package/package.json +3 -3
  3. package/prettier.config.js +4 -0
  4. package/src/controller/api.ts +226 -0
  5. package/src/controller/error.ts +13 -0
  6. package/src/controller/oauth/allowed.ts +22 -0
  7. package/src/controller/oauth/code-verifier.ts +11 -0
  8. package/src/controller/oauth/redirect.ts +12 -0
  9. package/src/controller/oauth.ts +333 -0
  10. package/src/controller/utils.ts +17 -0
  11. package/src/db/db.ts +100 -0
  12. package/src/db/encrypter.ts +38 -0
  13. package/src/db/mem.ts +128 -0
  14. package/src/db/mongo.ts +110 -0
  15. package/src/db/redis.ts +103 -0
  16. package/src/db/sql/entity/JacksonIndex.ts +44 -0
  17. package/src/db/sql/entity/JacksonStore.ts +43 -0
  18. package/src/db/sql/entity/JacksonTTL.ts +17 -0
  19. package/src/db/sql/model/JacksonIndex.ts +3 -0
  20. package/src/db/sql/model/JacksonStore.ts +8 -0
  21. package/src/db/sql/sql.ts +184 -0
  22. package/src/db/store.ts +49 -0
  23. package/src/db/utils.ts +26 -0
  24. package/src/env.ts +42 -0
  25. package/src/index.ts +79 -0
  26. package/src/jackson.ts +171 -0
  27. package/src/read-config.ts +29 -0
  28. package/src/saml/claims.js +40 -0
  29. package/src/saml/saml.ts +234 -0
  30. package/src/saml/x509.js +48 -0
  31. package/src/test/api.test.ts.disabled +271 -0
  32. package/src/test/data/metadata/boxyhq.js +6 -0
  33. package/src/test/data/metadata/boxyhq.xml +30 -0
  34. package/src/test/data/saml_response +1 -0
  35. package/src/test/db.test.ts +318 -0
  36. package/src/test/oauth.test.ts.disabled +353 -0
  37. package/src/typings.ts +167 -0
  38. package/tsconfig.build.json +6 -0
  39. package/tsconfig.json +26 -0
  40. package/.nyc_output/522d751d-0cf8-42cc-9e6b-7c4f2c2ab0d4.json +0 -1
  41. package/.nyc_output/93c45454-d3b6-48a7-9885-209592dc290a.json +0 -1
  42. package/.nyc_output/da9b997e-732d-4bf2-a4e8-4b0568635c06.json +0 -1
  43. package/.nyc_output/processinfo/522d751d-0cf8-42cc-9e6b-7c4f2c2ab0d4.json +0 -1
  44. package/.nyc_output/processinfo/93c45454-d3b6-48a7-9885-209592dc290a.json +0 -1
  45. package/.nyc_output/processinfo/da9b997e-732d-4bf2-a4e8-4b0568635c06.json +0 -1
  46. package/.nyc_output/processinfo/index.json +0 -1
@@ -0,0 +1,184 @@
1
+ /*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
2
+
3
+ require('reflect-metadata');
4
+
5
+ import { DatabaseDriver, DatabaseOption, Index } from 'saml-jackson';
6
+ import { Connection, createConnection } from 'typeorm';
7
+ import * as dbutils from '../utils';
8
+ import { JacksonIndex } from './model/JacksonIndex';
9
+ import { JacksonStore } from './model/JacksonStore';
10
+ import { JacksonTTL } from './entity/JacksonTTL';
11
+
12
+ import JacksonStoreEntity from './entity/JacksonStore';
13
+ import JacksonIndexEntity from './entity/JacksonIndex';
14
+ import { JacksonTTL as JacksonTTLEntity } from './entity/JacksonTTL';
15
+
16
+ class Sql implements DatabaseDriver {
17
+ private options: DatabaseOption;
18
+ private connection!: Connection;
19
+ private storeRepository; //!: typeorm.Repository<JacksonStore>;
20
+ private indexRepository; //!: typeorm.Repository<JacksonIndex>;
21
+ private ttlRepository; //!: typeorm.Repository<JacksonTTL>;
22
+ private ttlCleanup;
23
+ private timerId;
24
+
25
+ constructor(options: DatabaseOption) {
26
+ this.options = options;
27
+ }
28
+
29
+ async init(): Promise<Sql> {
30
+ while (true) {
31
+ try {
32
+ // TODO: Fix it
33
+ // @ts-ignore
34
+ this.connection = await createConnection({
35
+ name: this.options.type + Math.floor(Math.random() * 100000),
36
+ type: this.options.type,
37
+ url: this.options.url,
38
+ synchronize: true,
39
+ migrationsTableName: '_jackson_migrations',
40
+ logging: false,
41
+ entities: [
42
+ JacksonStoreEntity(this.options.type),
43
+ JacksonIndexEntity,
44
+ JacksonTTLEntity,
45
+ ],
46
+ });
47
+
48
+ break;
49
+ } catch (err) {
50
+ console.error(`error connecting to ${this.options.type} db: ${err}`);
51
+ await dbutils.sleep(1000);
52
+ continue;
53
+ }
54
+ }
55
+
56
+ this.storeRepository = this.connection.getRepository(JacksonStore);
57
+ this.indexRepository = this.connection.getRepository(JacksonIndex);
58
+ this.ttlRepository = this.connection.getRepository(JacksonTTL);
59
+
60
+ if (this.options.ttl && this.options.cleanupLimit) {
61
+ this.ttlCleanup = async () => {
62
+ const now = Date.now();
63
+
64
+ while (true) {
65
+ const ids = await this.ttlRepository
66
+ .createQueryBuilder('jackson_ttl')
67
+ .limit(this.options.cleanupLimit)
68
+ .where('jackson_ttl.expiresAt <= :expiresAt', {
69
+ expiresAt: now,
70
+ })
71
+ .getMany();
72
+
73
+ if (ids.length <= 0) {
74
+ break;
75
+ }
76
+
77
+ const delIds = ids.map((id) => {
78
+ return id.key;
79
+ });
80
+
81
+ await this.storeRepository.remove(ids);
82
+ await this.ttlRepository.delete(delIds);
83
+ }
84
+
85
+ this.timerId = setTimeout(this.ttlCleanup, this.options.ttl * 1000);
86
+ };
87
+
88
+ this.timerId = setTimeout(this.ttlCleanup, this.options.ttl * 1000);
89
+ } else {
90
+ console.log(
91
+ 'Warning: ttl cleanup not enabled, set both "ttl" and "cleanupLimit" options to enable it!'
92
+ );
93
+ }
94
+
95
+ return this;
96
+ }
97
+
98
+ async get(namespace: string, key: string): Promise<any> {
99
+ let res = await this.storeRepository.findOne({
100
+ key: dbutils.key(namespace, key),
101
+ });
102
+
103
+ if (res && res.value) {
104
+ return {
105
+ value: res.value,
106
+ iv: res.iv,
107
+ tag: res.tag,
108
+ };
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ async getByIndex(namespace: string, idx: Index): Promise<any> {
115
+ const res = await this.indexRepository.find({
116
+ key: dbutils.keyForIndex(namespace, idx),
117
+ });
118
+
119
+ const ret: string[] = [];
120
+
121
+ if (res) {
122
+ res.forEach((r) => {
123
+ // @ts-ignore
124
+ ret.push({
125
+ value: r.store.value,
126
+ iv: r.store.iv,
127
+ tag: r.store.tag,
128
+ });
129
+ });
130
+ }
131
+
132
+ return ret;
133
+ }
134
+
135
+ async put(
136
+ namespace: string,
137
+ key: string,
138
+ val: string,
139
+ ttl: number = 0,
140
+ ...indexes: any[]
141
+ ): Promise<void> {
142
+ await this.connection.transaction(async (transactionalEntityManager) => {
143
+ const dbKey = dbutils.key(namespace, key);
144
+
145
+ // @ts-ignore
146
+ const store = new JacksonStore(dbKey, val.value, val.iv, val.tag);
147
+
148
+ await transactionalEntityManager.save(store);
149
+
150
+ if (ttl) {
151
+ const ttlRec = new JacksonTTL();
152
+ ttlRec.key = dbKey;
153
+ ttlRec.expiresAt = Date.now() + ttl * 1000;
154
+ await transactionalEntityManager.save(ttlRec);
155
+ }
156
+
157
+ // no ttl support for secondary indexes
158
+ for (const idx of indexes || []) {
159
+ const key = dbutils.keyForIndex(namespace, idx);
160
+ const rec = await this.indexRepository.findOne({
161
+ key,
162
+ storeKey: store.key,
163
+ });
164
+ if (!rec) {
165
+ await transactionalEntityManager.save(
166
+ new JacksonIndex(0, key, store)
167
+ );
168
+ }
169
+ }
170
+ });
171
+ }
172
+
173
+ async delete(namespace: string, key: string): Promise<any> {
174
+ return await this.storeRepository.remove({
175
+ key: dbutils.key(namespace, key),
176
+ });
177
+ }
178
+ }
179
+
180
+ export default {
181
+ new: async (options: DatabaseOption): Promise<Sql> => {
182
+ return await new Sql(options).init();
183
+ },
184
+ };
@@ -0,0 +1,49 @@
1
+ import { Index, Storable } from 'saml-jackson';
2
+ import * as dbutils from './utils';
3
+
4
+ class Store implements Storable {
5
+ private namespace: string;
6
+ private db: any;
7
+ private ttl: number;
8
+
9
+ constructor(namespace: string, db: any, ttl: number = 0) {
10
+ this.namespace = namespace;
11
+ this.db = db;
12
+ this.ttl = ttl;
13
+ }
14
+
15
+ async get(key: string): Promise<any> {
16
+ return await this.db.get(this.namespace, dbutils.keyDigest(key));
17
+ }
18
+
19
+ async getByIndex(idx: Index): Promise<any> {
20
+ idx.value = dbutils.keyDigest(idx.value);
21
+
22
+ return await this.db.getByIndex(this.namespace, idx);
23
+ }
24
+
25
+ async put(key: string, val: any, ...indexes: Index[]): Promise<any> {
26
+ indexes = (indexes || []).map((idx) => {
27
+ idx.value = dbutils.keyDigest(idx.value);
28
+ return idx;
29
+ });
30
+
31
+ return await this.db.put(
32
+ this.namespace,
33
+ dbutils.keyDigest(key),
34
+ val,
35
+ this.ttl,
36
+ ...indexes
37
+ );
38
+ }
39
+
40
+ async delete(key: string): Promise<any> {
41
+ return await this.db.delete(this.namespace, dbutils.keyDigest(key));
42
+ }
43
+ }
44
+
45
+ export default {
46
+ new: (namespace: string, db: any, ttl: number = 0): Storable => {
47
+ return new Store(namespace, db, ttl);
48
+ },
49
+ };
@@ -0,0 +1,26 @@
1
+ import Ripemd160 from 'ripemd160';
2
+ import { Index } from 'saml-jackson';
3
+
4
+ export const key = (namespace: string, k: string): string => {
5
+ return namespace + ':' + k;
6
+ };
7
+
8
+ export const keyForIndex = (namespace: string, idx: Index): string => {
9
+ return key(key(namespace, idx.name), idx.value);
10
+ };
11
+
12
+ export const keyDigest = (k: string): string => {
13
+ return new Ripemd160().update(k).digest('hex');
14
+ };
15
+
16
+ export const keyFromParts = (...parts: string[]): string => {
17
+ // TODO: pick a better strategy, keys can collide now
18
+
19
+ return parts.join(':');
20
+ };
21
+
22
+ export const sleep = (ms: number): Promise<void> => {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ };
25
+
26
+ export const indexPrefix = '_index';
package/src/env.ts ADDED
@@ -0,0 +1,42 @@
1
+ const hostUrl = process.env.HOST_URL || 'localhost';
2
+ const hostPort = +(process.env.HOST_PORT || '5000');
3
+ const externalUrl =
4
+ process.env.EXTERNAL_URL || 'http://' + hostUrl + ':' + hostPort;
5
+ const samlPath = process.env.SAML_PATH || '/oauth/saml';
6
+
7
+ const internalHostUrl = process.env.INTERNAL_HOST_URL || 'localhost';
8
+ const internalHostPort = +(process.env.INTERNAL_HOST_PORT || '6000');
9
+
10
+ const apiKeys = (process.env.JACKSON_API_KEYS || '').split(',');
11
+
12
+ const samlAudience = process.env.SAML_AUDIENCE;
13
+ const preLoadedConfig = process.env.PRE_LOADED_CONFIG;
14
+
15
+ const idpEnabled = process.env.IDP_ENABLED;
16
+
17
+ const db = {
18
+ engine: process.env.DB_ENGINE,
19
+ url: process.env.DB_URL,
20
+ type: process.env.DB_TYPE,
21
+ ttl: process.env.DB_TTL,
22
+ encryptionKey: process.env.DB_ENCRYPTION_KEY,
23
+ };
24
+
25
+ const env = {
26
+ hostUrl,
27
+ hostPort,
28
+ externalUrl,
29
+ samlPath,
30
+ samlAudience,
31
+ preLoadedConfig,
32
+ internalHostUrl,
33
+ internalHostPort,
34
+ apiKeys,
35
+ idpEnabled,
36
+ db,
37
+ useInternalServer: !(
38
+ hostUrl === internalHostUrl && hostPort === internalHostPort
39
+ ),
40
+ };
41
+
42
+ export default env;
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { JacksonOption } from 'saml-jackson';
2
+ import { SAMLConfig } from './controller/api';
3
+ import { OAuthController } from './controller/oauth';
4
+ import DB from './db/db';
5
+ import readConfig from './read-config';
6
+
7
+ const defaultOpts = (opts: JacksonOption): JacksonOption => {
8
+ const newOpts = {
9
+ ...opts,
10
+ };
11
+
12
+ if (!newOpts.externalUrl) {
13
+ throw new Error('externalUrl is required');
14
+ }
15
+
16
+ if (!newOpts.samlPath) {
17
+ throw new Error('samlPath is required');
18
+ }
19
+
20
+ newOpts.samlAudience = newOpts.samlAudience || 'https://saml.boxyhq.com';
21
+ newOpts.preLoadedConfig = newOpts.preLoadedConfig || ''; // path to folder containing static SAML config that will be preloaded. This is useful for self-hosted deployments that only have to support a single tenant (or small number of known tenants).
22
+ newOpts.idpEnabled = newOpts.idpEnabled === true;
23
+
24
+ newOpts.db = newOpts.db || {};
25
+ newOpts.db.engine = newOpts.db.engine || 'sql';
26
+ newOpts.db.url =
27
+ newOpts.db.url || 'postgresql://postgres:postgres@localhost:5432/postgres';
28
+ newOpts.db.type = newOpts.db.type || 'postgres'; // Only needed if DB_ENGINE is sql.
29
+ newOpts.db.ttl = (newOpts.db.ttl || 300) * 1; // TTL for the code, session and token stores (in seconds)
30
+ newOpts.db.cleanupLimit = (newOpts.db.cleanupLimit || 1000) * 1; // Limit cleanup of TTL entries to this many items at a time
31
+
32
+ return newOpts;
33
+ };
34
+
35
+ export default async function controllers(
36
+ opts: JacksonOption
37
+ ): Promise<{ apiController: SAMLConfig; oauthController: OAuthController }> {
38
+ opts = defaultOpts(opts);
39
+
40
+ const db = await DB.new(opts.db);
41
+
42
+ const configStore = db.store('saml:config');
43
+ const sessionStore = db.store('oauth:session', opts.db.ttl);
44
+ const codeStore = db.store('oauth:code', opts.db.ttl);
45
+ const tokenStore = db.store('oauth:token', opts.db.ttl);
46
+
47
+ const apiController = new SAMLConfig({ configStore });
48
+
49
+ const oauthController = new OAuthController({
50
+ configStore,
51
+ sessionStore,
52
+ codeStore,
53
+ tokenStore,
54
+ opts,
55
+ });
56
+
57
+ // write pre-loaded config if present
58
+ if (opts.preLoadedConfig && opts.preLoadedConfig.length > 0) {
59
+ const configs = await readConfig(opts.preLoadedConfig);
60
+
61
+ for (const config of configs) {
62
+ await apiController.config(config);
63
+
64
+ console.log(
65
+ `loaded config for tenant "${config.tenant}" and product "${config.product}"`
66
+ );
67
+ }
68
+ }
69
+
70
+ const type =
71
+ opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
72
+
73
+ console.log(`Using engine: ${opts.db.engine}.${type}`);
74
+
75
+ return {
76
+ apiController,
77
+ oauthController,
78
+ };
79
+ }
package/src/jackson.ts ADDED
@@ -0,0 +1,171 @@
1
+ import cors from 'cors';
2
+ import express from 'express';
3
+ import { JacksonError } from './controller/error';
4
+ import { extractAuthToken } from './controller/utils';
5
+ import env from './env';
6
+ import jackson from './index';
7
+
8
+ let apiController;
9
+ let oauthController;
10
+
11
+ const oauthPath = '/oauth';
12
+ const apiPath = '/api/v1/saml';
13
+
14
+ const app = express();
15
+
16
+ app.use(express.json());
17
+ app.use(express.urlencoded({ extended: true }));
18
+
19
+ app.get(oauthPath + '/authorize', async (req, res) => {
20
+ try {
21
+ const { redirect_url } = await oauthController.authorize(req.query);
22
+
23
+ res.redirect(redirect_url);
24
+ } catch (err) {
25
+ const { message, statusCode = 500 } = err as JacksonError;
26
+
27
+ res.status(statusCode).send(message);
28
+ }
29
+ });
30
+
31
+ app.post(env.samlPath, async (req, res) => {
32
+ try {
33
+ const { redirect_url } = await oauthController.samlResponse(req.body);
34
+
35
+ res.redirect(redirect_url);
36
+ } catch (err) {
37
+ const { message, statusCode = 500 } = err as JacksonError;
38
+
39
+ res.status(statusCode).send(message);
40
+ }
41
+ });
42
+
43
+ app.post(oauthPath + '/token', cors(), async (req, res) => {
44
+ try {
45
+ const result = await oauthController.token(req.body);
46
+
47
+ res.json(result);
48
+ } catch (err) {
49
+ const { message, statusCode = 500 } = err as JacksonError;
50
+
51
+ res.status(statusCode).send(message);
52
+ }
53
+ });
54
+
55
+ app.get(oauthPath + '/userinfo', async (req, res) => {
56
+ try {
57
+ let token = extractAuthToken(req);
58
+
59
+ // check for query param
60
+ if (!token) {
61
+ token = req.query.access_token;
62
+ }
63
+
64
+ if (!token) {
65
+ res.status(401).json({ message: 'Unauthorized' });
66
+ }
67
+
68
+ const profile = await oauthController.userInfo(token);
69
+
70
+ res.json(profile);
71
+ } catch (err) {
72
+ const { message, statusCode = 500 } = err as JacksonError;
73
+
74
+ res.status(statusCode).json({ message });
75
+ }
76
+ });
77
+
78
+ const server = app.listen(env.hostPort, async () => {
79
+ console.log(
80
+ `🚀 The path of the righteous server: http://${env.hostUrl}:${env.hostPort}`
81
+ );
82
+
83
+ // TODO: Fix it
84
+ // @ts-ignore
85
+ const ctrlrModule = await jackson(env);
86
+
87
+ apiController = ctrlrModule.apiController;
88
+ oauthController = ctrlrModule.oauthController;
89
+ });
90
+
91
+ // Internal routes, recommended not to expose this to the public interface though it would be guarded by API key(s)
92
+ let internalApp = app;
93
+
94
+ if (env.useInternalServer) {
95
+ internalApp = express();
96
+
97
+ internalApp.use(express.json());
98
+ internalApp.use(express.urlencoded({ extended: true }));
99
+ }
100
+
101
+ const validateApiKey = (token) => {
102
+ return env.apiKeys.includes(token);
103
+ };
104
+
105
+ internalApp.post(apiPath + '/config', async (req, res) => {
106
+ try {
107
+ const apiKey = extractAuthToken(req);
108
+ if (!validateApiKey(apiKey)) {
109
+ res.status(401).send('Unauthorized');
110
+ return;
111
+ }
112
+
113
+ res.json(await apiController.config(req.body));
114
+ } catch (err) {
115
+ const { message } = err as JacksonError;
116
+
117
+ res.status(500).json({
118
+ error: message,
119
+ });
120
+ }
121
+ });
122
+
123
+ internalApp.get(apiPath + '/config', async (req, res) => {
124
+ try {
125
+ const apiKey = extractAuthToken(req);
126
+ if (!validateApiKey(apiKey)) {
127
+ res.status(401).send('Unauthorized');
128
+ return;
129
+ }
130
+
131
+ res.json(await apiController.getConfig(req.query));
132
+ } catch (err) {
133
+ const { message } = err as JacksonError;
134
+
135
+ res.status(500).json({
136
+ error: message,
137
+ });
138
+ }
139
+ });
140
+
141
+ internalApp.delete(apiPath + '/config', async (req, res) => {
142
+ try {
143
+ const apiKey = extractAuthToken(req);
144
+ if (!validateApiKey(apiKey)) {
145
+ res.status(401).send('Unauthorized');
146
+ return;
147
+ }
148
+ await apiController.deleteConfig(req.body);
149
+ res.status(200).end();
150
+ } catch (err) {
151
+ const { message } = err as JacksonError;
152
+
153
+ res.status(500).json({
154
+ error: message,
155
+ });
156
+ }
157
+ });
158
+
159
+ let internalServer = server;
160
+ if (env.useInternalServer) {
161
+ internalServer = internalApp.listen(env.internalHostPort, async () => {
162
+ console.log(
163
+ `🚀 The path of the righteous internal server: http://${env.internalHostUrl}:${env.internalHostPort}`
164
+ );
165
+ });
166
+ }
167
+
168
+ module.exports = {
169
+ server,
170
+ internalServer,
171
+ };
@@ -0,0 +1,29 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { IdPConfig } from 'saml-jackson';
4
+
5
+ const readConfig = async (preLoadedConfig: string): Promise<IdPConfig[]> => {
6
+ if (preLoadedConfig.startsWith('./')) {
7
+ preLoadedConfig = path.resolve(process.cwd(), preLoadedConfig);
8
+ }
9
+
10
+ const files = await fs.promises.readdir(preLoadedConfig);
11
+ const configs: IdPConfig[] = [];
12
+
13
+ for (let idx in files) {
14
+ const file = files[idx];
15
+ if (file.endsWith('.js')) {
16
+ const config = require(path.join(preLoadedConfig, file)) as IdPConfig;
17
+ const rawMetadata = await fs.promises.readFile(
18
+ path.join(preLoadedConfig, path.parse(file).name + '.xml'),
19
+ 'utf8'
20
+ );
21
+ config.rawMetadata = rawMetadata;
22
+ configs.push(config);
23
+ }
24
+ }
25
+
26
+ return configs;
27
+ };
28
+
29
+ export default readConfig;
@@ -0,0 +1,40 @@
1
+ const mapping = [
2
+ {
3
+ attribute: 'id',
4
+ schema:
5
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
6
+ },
7
+ {
8
+ attribute: 'email',
9
+ schema:
10
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
11
+ },
12
+ {
13
+ attribute: 'firstName',
14
+ schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
15
+ },
16
+ {
17
+ attribute: 'lastName',
18
+ schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
19
+ },
20
+ ];
21
+
22
+ const map = (claims) => {
23
+ const profile = {
24
+ raw: claims,
25
+ };
26
+
27
+ mapping.forEach((m) => {
28
+ if (claims[m.attribute]) {
29
+ profile[m.attribute] = claims[m.attribute];
30
+ } else if (claims[m.schema]) {
31
+ profile[m.attribute] = claims[m.schema];
32
+ }
33
+ });
34
+
35
+ return profile;
36
+ };
37
+
38
+ module.exports = {
39
+ map,
40
+ };