@toa.io/extensions.origins 0.8.0-dev.0 → 0.8.0-dev.2
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/package.json +6 -6
- package/readme.md +84 -13
- package/source/deployment.test.js +2 -3
- package/source/factory.js +17 -5
- package/source/factory.test.js +22 -4
- package/source/manifest.js +20 -5
- package/source/manifest.test.js +24 -0
- package/source/protocols/amqp/aspect.js +3 -1
- package/source/protocols/amqp/deployment.js +12 -6
- package/source/protocols/amqp/id.js +3 -0
- package/source/protocols/amqp/index.js +2 -0
- package/source/protocols/http/.aspect/permissions.js +65 -0
- package/source/protocols/http/aspect.js +47 -6
- package/source/protocols/http/aspect.test.js +99 -4
- package/source/protocols/http/id.js +3 -0
- package/source/protocols/http/index.js +2 -0
- package/types/http.d.ts +28 -0
- package/types/http.ts +0 -18
- /package/types/{amqp.ts → amqp.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.origins",
|
|
3
|
-
"version": "0.8.0-dev.
|
|
3
|
+
"version": "0.8.0-dev.2",
|
|
4
4
|
"description": "Toa Origins",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@toa.io/core": "0.8.0-dev.
|
|
23
|
-
"@toa.io/generic": "0.8.0-dev.
|
|
24
|
-
"@toa.io/schemas": "0.8.0-dev.
|
|
25
|
-
"@toa.io/yaml": "0.7.2-dev.
|
|
22
|
+
"@toa.io/core": "0.8.0-dev.2",
|
|
23
|
+
"@toa.io/generic": "0.8.0-dev.2",
|
|
24
|
+
"@toa.io/schemas": "0.8.0-dev.2",
|
|
25
|
+
"@toa.io/yaml": "0.7.2-dev.3",
|
|
26
26
|
"comq": "0.6.0",
|
|
27
27
|
"node-fetch": "2.6.7"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node-fetch": "2.6.2"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "85a6e6ce4f5d90af087507e3db65b94f32e9a818"
|
|
33
33
|
}
|
package/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Toa Origins
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Enables external communications over supported protocols (HTTP and AMQP).
|
|
4
4
|
|
|
5
5
|
## TL;DR
|
|
6
6
|
|
|
@@ -10,15 +10,19 @@ name: dummy
|
|
|
10
10
|
namespace: dummies
|
|
11
11
|
|
|
12
12
|
origins:
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
docs: http://www.domain.com/docs/
|
|
14
|
+
amazon: amqps://amqp.amazon.com
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```javascript
|
|
18
18
|
// Node.js bridge
|
|
19
19
|
async function transition (input, object, context) {
|
|
20
|
-
|
|
21
|
-
await context.
|
|
20
|
+
// direct Aspect invocation
|
|
21
|
+
await context.aspects.http('docs', './example', { method: 'GET' })
|
|
22
|
+
|
|
23
|
+
// shortcuts
|
|
24
|
+
await context.http.docs.example.get() // GET http://www.domain.com/docs/example
|
|
25
|
+
await context.amqp.amazon.emit('something_happened', { really: true })
|
|
22
26
|
}
|
|
23
27
|
```
|
|
24
28
|
|
|
@@ -26,25 +30,92 @@ async function transition (input, object, context) {
|
|
|
26
30
|
# context.toa.yaml
|
|
27
31
|
origins:
|
|
28
32
|
dummies.dummy:
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
amazon: amqps://amqp.azure.com
|
|
34
|
+
amazon@staging: amqp://amqp.stage
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
##
|
|
37
|
+
## Manifest
|
|
38
|
+
|
|
39
|
+
`origins` manifest is an object conforming declaring origin names as keys an origin URLs as values.
|
|
40
|
+
Component's `origins` manifest can be overridden by the Context `origins` annotation.
|
|
41
|
+
|
|
42
|
+
### Sharded Connections
|
|
43
|
+
|
|
44
|
+
Origin value may contain [shards](/libraries/generic/readme.md#shards) placeholders.
|
|
45
|
+
|
|
46
|
+
### Environment Variables
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
Origin value may contain environment variable placeholders.
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
# manifest.toa.yaml
|
|
52
|
+
origins:
|
|
53
|
+
foo@dev: stage${STAGE_NUMBER}.stages.com
|
|
54
|
+
```
|
|
37
55
|
|
|
38
|
-
## HTTP
|
|
56
|
+
## HTTP Aspect
|
|
39
57
|
|
|
40
58
|
Uses [node-fetch](https://github.com/node-fetch/node-fetch) and returns its result.
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
Aspect invocation function
|
|
61
|
+
signature: `async (origin: string, rel: string, reuest: fetch.Request): fetch.Response`
|
|
62
|
+
|
|
63
|
+
- `origin`: name of the origin in the manifest
|
|
64
|
+
- `rel`: relative reference to a resource
|
|
65
|
+
- `request`: `Request` form `node-fetch`
|
|
66
|
+
|
|
67
|
+
### Absolute URLs
|
|
68
|
+
|
|
69
|
+
Requests to arbitrary URLs can be implemented with overloaded direct Aspect invocation.
|
|
70
|
+
|
|
71
|
+
`async (url: string, request: fetch.Request): fetch.Response`
|
|
72
|
+
|
|
73
|
+
By default, requests to arbitrary URLs are not allowed and must be explicitly permitted by setting
|
|
74
|
+
permissions in the Origins Annotation.
|
|
75
|
+
|
|
76
|
+
The Rules object is stored in the `.http` property of the corresponding component. Each key in the
|
|
77
|
+
Rules object is a regular expression that URLs will be tested against, and each value is a
|
|
78
|
+
permission — either `true` to allow the URL or `false` to deny it. In cases where a URL matches
|
|
79
|
+
multiple rules, denial takes priority.
|
|
80
|
+
|
|
81
|
+
> The `null` key is a special case that represents "any URL".
|
|
82
|
+
|
|
83
|
+
#### Example
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
# context.toa.yaml
|
|
87
|
+
origins:
|
|
88
|
+
dummies.dummy:
|
|
89
|
+
.http:
|
|
90
|
+
/^https?:\/\/api.domain.com/: true
|
|
91
|
+
/^http:\/\/sandbox.domain.com/@staging: true # staging environment
|
|
92
|
+
/.*hackers.*/: false # deny rule
|
|
93
|
+
~: true # allow any URL
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
// Node.js bridge
|
|
98
|
+
async function transition (input, object, context) {
|
|
99
|
+
await context.aspects.http('https://api.domain.com/example', { method: 'POST' })
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### `null` origins
|
|
104
|
+
|
|
105
|
+
To enable the extension for a component that uses arbitrary URLs without any specific origins to
|
|
106
|
+
declare, the Origins manifest should be set to `null`.
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
# manifest.toa.yaml
|
|
110
|
+
origins: ~
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## AMQP Aspect
|
|
43
114
|
|
|
44
115
|
Uses [ComQ](https://github.com/toa-io/comq), thus, provides interface of `comq.IO` restricted
|
|
45
116
|
to `emit` and `request` methods.
|
|
46
117
|
|
|
47
|
-
AMQP origins
|
|
118
|
+
AMQP origins can have credential secrets deployed. Secret's name must
|
|
48
119
|
follow `toa-origins-{namespace}-{component}-{origin}` and it must have keys `username`
|
|
49
120
|
and `password`.
|
|
50
121
|
|
|
@@ -131,12 +131,11 @@ describe('amqp', () => {
|
|
|
131
131
|
|
|
132
132
|
expect(variables).toBeDefined()
|
|
133
133
|
|
|
134
|
-
const envPrefix = `TOA_ORIGINS_${component.locator.uppercase}_`
|
|
135
|
-
const
|
|
134
|
+
const envPrefix = `TOA_ORIGINS_${component.locator.uppercase}_${up(origin)}_`
|
|
135
|
+
const secretName = `toa-origins-${component.locator.label}-${origin}`
|
|
136
136
|
|
|
137
137
|
for (const property of ['username', 'password']) {
|
|
138
138
|
const variableName = envPrefix + up(property)
|
|
139
|
-
const secretName = secPrefix + property
|
|
140
139
|
const variable = findVariable(variables, variableName)
|
|
141
140
|
|
|
142
141
|
expect(variable).toBeDefined()
|
package/source/factory.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
const protocols = require('./protocols')
|
|
4
4
|
const env = require('./env')
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* @implements {toa.core.extensions.Factory}
|
|
8
|
-
*/
|
|
9
6
|
class Factory {
|
|
7
|
+
/**
|
|
8
|
+
* @param {toa.core.Locator} locator
|
|
9
|
+
* @param {toa.origins.Manifest} manifest
|
|
10
|
+
* @return {toa.core.extensions.Aspect[]}
|
|
11
|
+
*/
|
|
10
12
|
aspect (locator, manifest) {
|
|
11
|
-
env.apply(locator,
|
|
13
|
+
env.apply(locator, manifest)
|
|
12
14
|
|
|
13
15
|
return protocols.map((protocol) => this.#createAspect(protocol, manifest))
|
|
14
16
|
}
|
|
@@ -21,13 +23,23 @@ class Factory {
|
|
|
21
23
|
#createAspect (protocol, manifest) {
|
|
22
24
|
const protocolManifest = {}
|
|
23
25
|
|
|
26
|
+
let properties
|
|
27
|
+
|
|
28
|
+
// let properties
|
|
29
|
+
|
|
24
30
|
for (const [origin, reference] of Object.entries(manifest)) {
|
|
31
|
+
if (origin[0] === '.') {
|
|
32
|
+
if (origin.substring(1) === protocol.id) properties = reference
|
|
33
|
+
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const url = new URL(reference)
|
|
26
38
|
|
|
27
39
|
if (protocol.protocols.includes(url.protocol)) protocolManifest[origin] = reference
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
return protocol.create(protocolManifest)
|
|
42
|
+
return protocol.create(protocolManifest, properties)
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
package/source/factory.test.js
CHANGED
|
@@ -13,7 +13,6 @@ const amqp = require('./protocols/amqp/aspect')
|
|
|
13
13
|
const fixtures = require('./.test/factory.fixtures')
|
|
14
14
|
const { Factory } = require('../')
|
|
15
15
|
|
|
16
|
-
/** @type {toa.core.extensions.Factory} */
|
|
17
16
|
let factory
|
|
18
17
|
|
|
19
18
|
beforeEach(() => {
|
|
@@ -28,8 +27,8 @@ it('should create aspects', () => {
|
|
|
28
27
|
const httpManifest = filterManifest(fixtures.manifest, 'http')
|
|
29
28
|
const amqpManifest = filterManifest(fixtures.manifest, 'amqp')
|
|
30
29
|
|
|
31
|
-
expect(http.create).toHaveBeenCalledWith(httpManifest)
|
|
32
|
-
expect(amqp.create).toHaveBeenCalledWith(amqpManifest)
|
|
30
|
+
expect(http.create).toHaveBeenCalledWith(httpManifest, undefined)
|
|
31
|
+
expect(amqp.create).toHaveBeenCalledWith(amqpManifest, undefined)
|
|
33
32
|
})
|
|
34
33
|
|
|
35
34
|
describe('env', () => {
|
|
@@ -47,7 +46,7 @@ describe('env', () => {
|
|
|
47
46
|
|
|
48
47
|
const expected = overwrite(httpManifest, override)
|
|
49
48
|
|
|
50
|
-
expect(http.create).
|
|
49
|
+
expect(http.create.mock.calls[0][0]).toStrictEqual(expected)
|
|
51
50
|
})
|
|
52
51
|
|
|
53
52
|
describe('amqp', () => {
|
|
@@ -104,6 +103,25 @@ describe('env', () => {
|
|
|
104
103
|
expect(url.password).toStrictEqual(password)
|
|
105
104
|
})
|
|
106
105
|
})
|
|
106
|
+
|
|
107
|
+
describe('http', () => {
|
|
108
|
+
it('should read properties', async () => {
|
|
109
|
+
const properties = {
|
|
110
|
+
'.http': {
|
|
111
|
+
[generate()]: generate()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const locator = new Locator(generate(), generate())
|
|
116
|
+
const json = JSON.stringify(properties)
|
|
117
|
+
|
|
118
|
+
process.env['TOA_ORIGINS_' + locator.uppercase] = btoa(json)
|
|
119
|
+
|
|
120
|
+
factory.aspect(locator, fixtures.manifest)
|
|
121
|
+
|
|
122
|
+
expect(http.create).toHaveBeenCalledWith(expect.anything(), properties['.http'])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
107
125
|
})
|
|
108
126
|
|
|
109
127
|
/**
|
package/source/manifest.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { remap, echo, shards } = require('@toa.io/generic')
|
|
3
4
|
const schemas = require('./schemas')
|
|
4
5
|
const protocols = require('./protocols')
|
|
5
6
|
|
|
@@ -8,16 +9,30 @@ const protocols = require('./protocols')
|
|
|
8
9
|
* @returns {toa.origins.Manifest}
|
|
9
10
|
*/
|
|
10
11
|
function manifest (manifest) {
|
|
11
|
-
|
|
12
|
+
if (manifest === null) return {}
|
|
13
|
+
|
|
14
|
+
manifest = remap(manifest, echo)
|
|
15
|
+
validate(manifest)
|
|
12
16
|
|
|
13
|
-
for (const
|
|
14
|
-
const
|
|
15
|
-
const supported = protocols.find((provider) => provider.protocols.includes(protocol))
|
|
17
|
+
for (const url of Object.values(manifest)) {
|
|
18
|
+
const supported = protocols.find((provider) => supports(provider, url))
|
|
16
19
|
|
|
17
|
-
if (supported === undefined) throw new Error(`'${
|
|
20
|
+
if (supported === undefined) throw new Error(`'${url}' protocol is not supported`)
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
return manifest
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* @param {toa.origins.Manifest} manifest
|
|
28
|
+
*/
|
|
29
|
+
function validate (manifest) {
|
|
30
|
+
manifest = remap(manifest, (value) => shards(value)[0])
|
|
31
|
+
schemas.manifest.validate(manifest)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function supports (provider, url) {
|
|
35
|
+
return provider.protocols.findIndex((protocol) => url.substring(0, protocol.length) === protocol) !== -1
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
exports.manifest = manifest
|
package/source/manifest.test.js
CHANGED
|
@@ -44,8 +44,32 @@ it('should throw if protocol is not supported', async () => {
|
|
|
44
44
|
expect(() => manifest(input)).toThrow('is not supported')
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
it('should convert null to {}', async () => {
|
|
48
|
+
const output = manifest(null)
|
|
49
|
+
|
|
50
|
+
expect(output).toStrictEqual({})
|
|
51
|
+
})
|
|
52
|
+
|
|
47
53
|
it.each(PROTOCOLS)('should support %s protocol', async (protocol) => {
|
|
48
54
|
const input = { foo: protocol + '//' + generate() }
|
|
49
55
|
|
|
50
56
|
expect(() => manifest(input)).not.toThrow()
|
|
51
57
|
})
|
|
58
|
+
|
|
59
|
+
it('should handle placeholders', async () => {
|
|
60
|
+
const input = { foo: 'http://${FOO}' + generate() + ':${BAR}/' } // eslint-disable-line no-template-curly-in-string
|
|
61
|
+
|
|
62
|
+
expect(() => manifest(input)).not.toThrow()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should handle host shards', async () => {
|
|
66
|
+
const input = { foo: 'http://{0-3}' + generate() }
|
|
67
|
+
|
|
68
|
+
expect(() => manifest(input)).not.toThrow()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should handle port shards', async () => {
|
|
72
|
+
const input = { foo: 'http://' + generate() + ':888{0-9}' }
|
|
73
|
+
|
|
74
|
+
expect(() => manifest(input)).not.toThrow()
|
|
75
|
+
})
|
|
@@ -4,11 +4,13 @@ const { connect } = require('comq')
|
|
|
4
4
|
const { Connector } = require('@toa.io/core')
|
|
5
5
|
const { shards } = require('@toa.io/generic')
|
|
6
6
|
|
|
7
|
+
const { id } = require('./id')
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* @implements {toa.origins.amqp.Aspect}
|
|
9
11
|
*/
|
|
10
12
|
class Aspect extends Connector {
|
|
11
|
-
name =
|
|
13
|
+
name = id
|
|
12
14
|
/** @type {toa.origins.Manifest} */
|
|
13
15
|
#manifest
|
|
14
16
|
|
|
@@ -12,13 +12,19 @@ function deployment (instances) {
|
|
|
12
12
|
const variables = {}
|
|
13
13
|
|
|
14
14
|
for (const { locator, manifest } of instances) {
|
|
15
|
+
const secrets = []
|
|
16
|
+
|
|
15
17
|
for (const [origin, reference] of Object.entries(manifest)) {
|
|
16
18
|
const url = new URL(reference)
|
|
17
19
|
|
|
18
20
|
if (protocols.includes(url.protocol)) {
|
|
19
|
-
|
|
21
|
+
const originSecrets = createSecrets(locator, origin)
|
|
22
|
+
|
|
23
|
+
secrets.push(...originSecrets)
|
|
20
24
|
}
|
|
21
25
|
}
|
|
26
|
+
|
|
27
|
+
variables[locator.label] = secrets
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
return variables
|
|
@@ -29,10 +35,10 @@ function deployment (instances) {
|
|
|
29
35
|
* @param {string} origin
|
|
30
36
|
* @return {toa.deployment.dependency.Variable[]}
|
|
31
37
|
*/
|
|
32
|
-
function
|
|
38
|
+
function createSecrets (locator, origin) {
|
|
33
39
|
const properties = ['username', 'password']
|
|
34
40
|
|
|
35
|
-
return properties.map((property) =>
|
|
41
|
+
return properties.map((property) => createSecret(locator, origin, property))
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/**
|
|
@@ -41,9 +47,9 @@ function secrets (locator, origin) {
|
|
|
41
47
|
* @param {string} property
|
|
42
48
|
* @return {toa.deployment.dependency.Variable}
|
|
43
49
|
*/
|
|
44
|
-
function
|
|
45
|
-
const variable = `TOA_ORIGINS_${locator.uppercase}_${up(property)}`
|
|
46
|
-
const secret = `toa-origins-${locator.label}-${
|
|
50
|
+
function createSecret (locator, origin, property) {
|
|
51
|
+
const variable = `TOA_ORIGINS_${locator.uppercase}_${up(origin)}_${up(property)}`
|
|
52
|
+
const secret = `toa-origins-${locator.label}-${origin}`
|
|
47
53
|
|
|
48
54
|
return {
|
|
49
55
|
name: variable,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const protocols = require('./protocols')
|
|
4
|
+
const { id } = require('./id')
|
|
4
5
|
const { create } = require('./aspect')
|
|
5
6
|
const { deployment } = require('./deployment')
|
|
6
7
|
|
|
7
8
|
exports.protocols = protocols
|
|
9
|
+
exports.id = id
|
|
8
10
|
exports.create = create
|
|
9
11
|
exports.deployment = deployment
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @implements {toa.origins.http.Permissions}
|
|
5
|
+
*/
|
|
6
|
+
class Permissions {
|
|
7
|
+
#default = process.env.TOA_DEV === '1'
|
|
8
|
+
|
|
9
|
+
/** @type {RegExp[]} */
|
|
10
|
+
#allowances = []
|
|
11
|
+
|
|
12
|
+
/** @type {RegExp[]} */
|
|
13
|
+
#denials = []
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {toa.origins.http.Properties} properties
|
|
17
|
+
*/
|
|
18
|
+
constructor (properties) {
|
|
19
|
+
if (properties !== undefined) this.#parse(properties)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test (url) {
|
|
23
|
+
const denial = this.#denials.findIndex((regexp) => regexp.test(url))
|
|
24
|
+
|
|
25
|
+
if (denial !== -1) return false
|
|
26
|
+
|
|
27
|
+
const allowance = this.#allowances.findIndex((regexp) => regexp.test(url))
|
|
28
|
+
|
|
29
|
+
if (allowance !== -1) return true
|
|
30
|
+
|
|
31
|
+
return this.#default
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#parse (properties) {
|
|
35
|
+
if ('null' in properties) {
|
|
36
|
+
const always = /** @type {RegExp} */ { test: () => true }
|
|
37
|
+
|
|
38
|
+
this.#addRule(always, properties.null)
|
|
39
|
+
delete properties.null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [key, rule] of Object.entries(properties)) {
|
|
43
|
+
const match = key.match(EXPRESSION)
|
|
44
|
+
|
|
45
|
+
if (match === null) throw new Error(`'${key}' is not a regular expression`)
|
|
46
|
+
|
|
47
|
+
const regex = new RegExp(match.groups.expression)
|
|
48
|
+
|
|
49
|
+
this.#addRule(regex, rule)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {RegExp} regex
|
|
55
|
+
* @param {boolean} rule
|
|
56
|
+
*/
|
|
57
|
+
#addRule (regex, rule) {
|
|
58
|
+
if (rule === true) this.#allowances.push(regex)
|
|
59
|
+
else this.#denials.push(regex)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EXPRESSION = /^\/(?<expression>.+)\/$/
|
|
64
|
+
|
|
65
|
+
exports.Permissions = Permissions
|
|
@@ -5,29 +5,48 @@ const fetch = require('node-fetch')
|
|
|
5
5
|
const { Connector } = require('@toa.io/core')
|
|
6
6
|
const { retry } = require('@toa.io/generic')
|
|
7
7
|
|
|
8
|
+
const { Permissions } = require('./.aspect/permissions')
|
|
9
|
+
const { id } = require('./id')
|
|
10
|
+
const protocols = require('./protocols')
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* @implements {toa.origins.http.Aspect}
|
|
10
14
|
*/
|
|
11
15
|
class Aspect extends Connector {
|
|
12
16
|
/** @readonly */
|
|
13
|
-
name =
|
|
17
|
+
name = id
|
|
14
18
|
|
|
15
19
|
/** @type {toa.origins.Manifest} */
|
|
16
20
|
#origins
|
|
17
21
|
|
|
22
|
+
/** @type {toa.origins.http.Permissions} */
|
|
23
|
+
#permissions
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* @param {toa.origins.Manifest} manifest
|
|
27
|
+
* @param {toa.origins.http.Permissions} permissions
|
|
20
28
|
*/
|
|
21
|
-
constructor (manifest) {
|
|
29
|
+
constructor (manifest, permissions) {
|
|
22
30
|
super()
|
|
23
31
|
|
|
24
32
|
this.#origins = manifest
|
|
33
|
+
this.#permissions = permissions
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
async invoke (name, path, request, options) {
|
|
28
37
|
let origin = this.#origins[name]
|
|
29
38
|
|
|
30
|
-
if (origin === undefined)
|
|
39
|
+
if (origin === undefined) {
|
|
40
|
+
if (isAbsoluteURL(/** @type {string} */ name)) {
|
|
41
|
+
return this.#invokeURL(
|
|
42
|
+
/** @type {string} */ name,
|
|
43
|
+
/** @type {import('node-fetch').RequestInit} */ path
|
|
44
|
+
)
|
|
45
|
+
} else throw new Error(`Origin '${name}' is not defined`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// absolute urls are forbidden when using origins
|
|
49
|
+
if (typeof path === 'string' && isAbsoluteURL(path)) throw new Error(`Absolute URLs are forbidden (${path})`)
|
|
31
50
|
|
|
32
51
|
if (options?.substitutions !== undefined) origin = substitute(origin, options.substitutions)
|
|
33
52
|
|
|
@@ -36,6 +55,17 @@ class Aspect extends Connector {
|
|
|
36
55
|
return this.#request(url.href, request, options?.retry)
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} url
|
|
60
|
+
* @param {import('node-fetch').RequestInit} request
|
|
61
|
+
* @return {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
async #invokeURL (url, request) {
|
|
64
|
+
if (this.#permissions.test(url) === false) throw new Error(`URL '${url}' is not allowed`)
|
|
65
|
+
|
|
66
|
+
return this.#request(url, request)
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* @param {string} url
|
|
41
71
|
* @param {import('node-fetch').RequestInit} request
|
|
@@ -70,19 +100,30 @@ class Aspect extends Connector {
|
|
|
70
100
|
* @param {string[]} substitutions
|
|
71
101
|
* @returns {string}
|
|
72
102
|
*/
|
|
73
|
-
|
|
103
|
+
function substitute (origin, substitutions) {
|
|
74
104
|
const replace = () => substitutions.shift()
|
|
75
105
|
|
|
76
106
|
return origin.replace(PLACEHOLDER, replace)
|
|
77
107
|
}
|
|
78
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} path
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function isAbsoluteURL (path) {
|
|
114
|
+
return protocols.findIndex((protocol) => path.indexOf(protocol) === 0) !== -1
|
|
115
|
+
}
|
|
116
|
+
|
|
79
117
|
const PLACEHOLDER = /\*/g
|
|
80
118
|
|
|
81
119
|
/**
|
|
82
120
|
* @param {toa.origins.Manifest} manifest
|
|
121
|
+
* @param {toa.origins.http.Properties} [properties]
|
|
83
122
|
*/
|
|
84
|
-
function create (manifest) {
|
|
85
|
-
|
|
123
|
+
function create (manifest, properties) {
|
|
124
|
+
const permissions = new Permissions(properties)
|
|
125
|
+
|
|
126
|
+
return new Aspect(manifest, permissions)
|
|
86
127
|
}
|
|
87
128
|
|
|
88
129
|
exports.create = create
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
const clone = require('clone-deep')
|
|
4
4
|
const { generate } = require('randomstring')
|
|
5
5
|
const { random } = require('@toa.io/generic')
|
|
6
|
-
|
|
7
6
|
const { Connector } = require('@toa.io/core')
|
|
8
7
|
|
|
8
|
+
/** @type {string[]} */
|
|
9
|
+
const protocols = require('../http/protocols')
|
|
10
|
+
|
|
9
11
|
const fixtures = require('./.test/aspect.fixtures')
|
|
10
12
|
const mock = fixtures.mock
|
|
11
13
|
|
|
@@ -13,7 +15,7 @@ jest.mock('node-fetch', () => mock.fetch)
|
|
|
13
15
|
|
|
14
16
|
const { create } = require('./aspect')
|
|
15
17
|
|
|
16
|
-
/** @type {toa.
|
|
18
|
+
/** @type {toa.origins.http.Aspect} */ let aspect
|
|
17
19
|
|
|
18
20
|
beforeEach(() => {
|
|
19
21
|
jest.clearAllMocks()
|
|
@@ -45,7 +47,7 @@ describe('invoke', () => {
|
|
|
45
47
|
|
|
46
48
|
beforeEach(async () => {
|
|
47
49
|
jest.clearAllMocks()
|
|
48
|
-
|
|
50
|
+
mock.fetch.reset()
|
|
49
51
|
mock.fetch.respond(200, response)
|
|
50
52
|
|
|
51
53
|
result = await aspect.invoke(name, path, clone(request))
|
|
@@ -68,6 +70,15 @@ describe('invoke', () => {
|
|
|
68
70
|
expect(mock.fetch.mock.calls[0][0]).toStrictEqual(fixtures.manifest.deep + path)
|
|
69
71
|
})
|
|
70
72
|
|
|
73
|
+
it.each(protocols)('should throw on absolute URL (%s)',
|
|
74
|
+
async (protocol) => {
|
|
75
|
+
jest.clearAllMocks()
|
|
76
|
+
mock.fetch.respond(200, response)
|
|
77
|
+
|
|
78
|
+
await expect(aspect.invoke('deep', protocol + '//api.domain.com', clone(request)))
|
|
79
|
+
.rejects.toThrow('Absolute URLs are forbidden')
|
|
80
|
+
})
|
|
81
|
+
|
|
71
82
|
it('should substitute wildcards', async () => {
|
|
72
83
|
jest.clearAllMocks()
|
|
73
84
|
mock.fetch.respond(200, response)
|
|
@@ -98,6 +109,7 @@ describe('invoke', () => {
|
|
|
98
109
|
jest.clearAllMocks()
|
|
99
110
|
mock.fetch.respond(200, response)
|
|
100
111
|
|
|
112
|
+
// noinspection JSCheckFunctionSignatures
|
|
101
113
|
expect(() => aspect.invoke(name)).not.toThrow()
|
|
102
114
|
})
|
|
103
115
|
|
|
@@ -125,7 +137,7 @@ describe('invoke', () => {
|
|
|
125
137
|
it('should retry', async () => {
|
|
126
138
|
jest.clearAllMocks()
|
|
127
139
|
|
|
128
|
-
const attempts = random(5) +
|
|
140
|
+
const attempts = random(5) + 2
|
|
129
141
|
|
|
130
142
|
for (let i = 1; i < attempts; i++) mock.fetch.respond(500)
|
|
131
143
|
|
|
@@ -142,3 +154,86 @@ describe('invoke', () => {
|
|
|
142
154
|
})
|
|
143
155
|
})
|
|
144
156
|
})
|
|
157
|
+
|
|
158
|
+
describe.each(protocols)('absolute URL', (protocol) => {
|
|
159
|
+
const response = { [generate()]: generate() }
|
|
160
|
+
|
|
161
|
+
it('should request absolute URL', async () => {
|
|
162
|
+
mock.fetch.respond(200, response)
|
|
163
|
+
|
|
164
|
+
const properties = { null: true }
|
|
165
|
+
const url = protocol + '//' + generate()
|
|
166
|
+
const request = { method: 'POST' }
|
|
167
|
+
|
|
168
|
+
aspect = create(fixtures.manifest, properties)
|
|
169
|
+
|
|
170
|
+
await aspect.invoke(url, request)
|
|
171
|
+
|
|
172
|
+
expect(mock.fetch).toHaveBeenCalledWith(url, request)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should allow if TOA_DEV=1 and no properties', async () => {
|
|
176
|
+
const dev = process.env.TOA_DEV
|
|
177
|
+
|
|
178
|
+
process.env.TOA_DEV = '1'
|
|
179
|
+
|
|
180
|
+
mock.fetch.respond(200, response)
|
|
181
|
+
|
|
182
|
+
const url = protocol + '//' + generate()
|
|
183
|
+
const request = { method: 'POST' }
|
|
184
|
+
|
|
185
|
+
aspect = create(fixtures.manifest)
|
|
186
|
+
|
|
187
|
+
await aspect.invoke(url, request)
|
|
188
|
+
|
|
189
|
+
expect(mock.fetch).toHaveBeenCalledWith(url, request)
|
|
190
|
+
|
|
191
|
+
process.env.TOA_DEV = dev
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should throw if URL not allowed', async () => {
|
|
195
|
+
mock.fetch.respond(200, response)
|
|
196
|
+
|
|
197
|
+
const properties = {}
|
|
198
|
+
|
|
199
|
+
aspect = create(fixtures.manifest, properties)
|
|
200
|
+
|
|
201
|
+
const url = protocol + '//' + generate()
|
|
202
|
+
const request = { method: 'POST' }
|
|
203
|
+
|
|
204
|
+
await expect(aspect.invoke(url, request)).rejects.toThrow('is not allowed')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it.each([
|
|
208
|
+
[String.raw`/^${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`],
|
|
209
|
+
[String.raw`/${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`]
|
|
210
|
+
])('should allow requests %s', async (expression, url) => {
|
|
211
|
+
mock.fetch.respond(200, response)
|
|
212
|
+
|
|
213
|
+
const properties = { [expression]: true }
|
|
214
|
+
|
|
215
|
+
aspect = create(fixtures.manifest, properties)
|
|
216
|
+
|
|
217
|
+
await expect(aspect.invoke(url)).resolves.not.toThrow()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it.each([
|
|
221
|
+
[String.raw`/^${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`],
|
|
222
|
+
[String.raw`/${protocol}\/\/api.\S+.com/`, `${protocol}//api.${generate()}.com/path/to`]
|
|
223
|
+
])('should allow requests except %s', async (expression, url) => {
|
|
224
|
+
mock.fetch.respond(200, response)
|
|
225
|
+
|
|
226
|
+
const properties = { null: true, [expression]: false }
|
|
227
|
+
|
|
228
|
+
aspect = create(fixtures.manifest, properties)
|
|
229
|
+
|
|
230
|
+
await expect(aspect.invoke(url)).rejects.toThrow('is not allowed')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.each([
|
|
234
|
+
['starts', 'expression/'],
|
|
235
|
+
['ends', '/expression']
|
|
236
|
+
])('should throw if rule does not %s with /', async (_, expression) => {
|
|
237
|
+
expect(() => create(fixtures.manifest, { [expression]: true })).toThrow('is not a regular expression')
|
|
238
|
+
})
|
|
239
|
+
})
|
package/types/http.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as fetch from 'node-fetch'
|
|
2
|
+
import * as _extensions from '@toa.io/core/types/extensions'
|
|
3
|
+
import * as _retry from '@toa.io/generic/types/retry'
|
|
4
|
+
|
|
5
|
+
declare namespace toa.origins.http {
|
|
6
|
+
|
|
7
|
+
namespace invocation {
|
|
8
|
+
type Options = {
|
|
9
|
+
substitutions?: string[]
|
|
10
|
+
retry?: _retry.Options
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Properties = Record<string | null, boolean>
|
|
15
|
+
|
|
16
|
+
interface Permissions {
|
|
17
|
+
test(url: string): boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Aspect extends _extensions.Aspect {
|
|
21
|
+
invoke(origin: string, path: string, request?: fetch.RequestInit, options?: invocation.Options): Promise<fetch.Response>
|
|
22
|
+
|
|
23
|
+
invoke(url: string, request?: fetch.RequestInit): Promise<fetch.Response>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Aspect = toa.origins.http.Aspect
|
package/types/http.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import * as fetch from 'node-fetch'
|
|
2
|
-
import * as _extensions from '@toa.io/core/types/extensions'
|
|
3
|
-
import * as _retry from '@toa.io/generic/types/retry'
|
|
4
|
-
|
|
5
|
-
declare namespace toa.origins.http {
|
|
6
|
-
|
|
7
|
-
namespace invocation {
|
|
8
|
-
type Options = {
|
|
9
|
-
substitutions?: string[]
|
|
10
|
-
retry?: _retry.Options
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface Aspect extends _extensions.Aspect {
|
|
15
|
-
invoke(name: string, path: string, request: fetch.RequestInit, options?: invocation.Options): Promise<fetch.Response>
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
}
|
|
File without changes
|