@toa.io/extensions.exposition 1.0.0-alpha.49 → 1.0.0-alpha.50

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 (57) hide show
  1. package/components/identity.federation/manifest.toa.yaml +4 -0
  2. package/components/identity.federation/operations/authenticate.js +2 -2
  3. package/components/identity.federation/operations/authenticate.js.map +1 -1
  4. package/components/identity.federation/operations/lib/jwt.js +10 -5
  5. package/components/identity.federation/operations/lib/jwt.js.map +1 -1
  6. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  7. package/components/identity.federation/operations/types/context.d.ts +2 -1
  8. package/components/identity.federation/source/authenticate.ts +2 -2
  9. package/components/identity.federation/source/lib/jwt.test.ts +48 -2
  10. package/components/identity.federation/source/lib/jwt.ts +14 -5
  11. package/components/identity.federation/source/types/context.ts +2 -1
  12. package/documentation/access.md +52 -26
  13. package/documentation/authorities.md +3 -7
  14. package/documentation/tree.md +13 -0
  15. package/features/auth.claim.feature +170 -0
  16. package/features/authorities.feature +3 -3
  17. package/features/authorities.federation.feature +4 -3
  18. package/features/authorities.tokens.feature +1 -2
  19. package/features/identity.federation.feature +2 -2
  20. package/features/routes.feature +63 -0
  21. package/features/steps/IdP.ts +2 -2
  22. package/features/steps/components/echo.beacon/manifest.toa.yaml +2 -0
  23. package/features/steps/components/echo.beacon/operations/hello.js +5 -0
  24. package/features/vary.feature +1 -2
  25. package/package.json +7 -7
  26. package/schemas/node.cos.yaml +1 -0
  27. package/source/Gateway.ts +28 -7
  28. package/source/HTTP/Server.ts +2 -7
  29. package/source/RTD/Node.ts +8 -1
  30. package/source/RTD/Route.ts +1 -1
  31. package/source/RTD/factory.ts +4 -1
  32. package/source/RTD/syntax/parse.ts +37 -24
  33. package/source/RTD/syntax/types.ts +1 -0
  34. package/source/directives/auth/Authorization.ts +5 -2
  35. package/source/directives/auth/Federation.ts +84 -0
  36. package/transpiled/Gateway.d.ts +1 -0
  37. package/transpiled/Gateway.js +20 -4
  38. package/transpiled/Gateway.js.map +1 -1
  39. package/transpiled/HTTP/Server.js +2 -5
  40. package/transpiled/HTTP/Server.js.map +1 -1
  41. package/transpiled/RTD/Node.d.ts +2 -0
  42. package/transpiled/RTD/Node.js +7 -1
  43. package/transpiled/RTD/Node.js.map +1 -1
  44. package/transpiled/RTD/Route.d.ts +1 -1
  45. package/transpiled/RTD/Route.js.map +1 -1
  46. package/transpiled/RTD/factory.js +4 -1
  47. package/transpiled/RTD/factory.js.map +1 -1
  48. package/transpiled/RTD/syntax/parse.js +34 -22
  49. package/transpiled/RTD/syntax/parse.js.map +1 -1
  50. package/transpiled/RTD/syntax/types.d.ts +1 -0
  51. package/transpiled/RTD/syntax/types.js.map +1 -1
  52. package/transpiled/directives/auth/Authorization.js +3 -1
  53. package/transpiled/directives/auth/Authorization.js.map +1 -1
  54. package/transpiled/directives/auth/Federation.d.ts +16 -0
  55. package/transpiled/directives/auth/Federation.js +57 -0
  56. package/transpiled/directives/auth/Federation.js.map +1 -0
  57. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -37,11 +37,8 @@ the directive's value.
37
37
  Given the Route declaration and corresponding HTTP request:
38
38
 
39
39
  ```yaml
40
- # context.toa.yaml
41
-
42
- exposition:
43
- /users/:user-id:
44
- id: "user-id"
40
+ /users/:user-id:
41
+ id: "user-id"
45
42
  ```
46
43
 
47
44
  ```http
@@ -57,11 +54,8 @@ is `87480f2bd88048518c529d7957475ecd`.
57
54
  Grants access if resolved Identity has a role matching the directive's value or one of its values.
58
55
 
59
56
  ```yaml
60
- # context.toa.yaml
61
-
62
- exposition:
63
- /code:
64
- role: [developer, reviewer]
57
+ /code:
58
+ role: [developer, reviewer]
65
59
  ```
66
60
 
67
61
  Access will be granted if the resolved Identity has a role that matches `developer` or `reviewer`.
@@ -73,11 +67,49 @@ Read [Roles](#roles) section for more details.
73
67
  The `role` directive can be used with a placeholder in the route.
74
68
 
75
69
  ```yaml
76
- # context.toa.yaml
70
+ /:org-id:
71
+ role: app:{org-id}:moderator
72
+ ```
77
73
 
78
- exposition:
79
- /:org-id:
80
- role: app:{org-id}:moderator
74
+ ### `claim`
75
+
76
+ Grants access if `Bearer` authentication scheme is used and the claim's property matches specified.
77
+
78
+ ```yaml
79
+ /:
80
+ auth:claim:
81
+ iss: https://id.example.com
82
+ sub: someone
83
+ aud: stars
84
+ ```
85
+
86
+ > If OIDC token claim contains `aud`
87
+ > as [an array](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation), the
88
+ > directive will match if at least one value.
89
+
90
+ At least one property is required.
91
+
92
+ Values may refer to the Route parameters, or a request authority:
93
+
94
+ ```yaml
95
+ /secrets/:org-id:
96
+ auth:claim:
97
+ iss: https://id.org.com
98
+ sub: /:org-id
99
+ aud: :authority
100
+ ```
101
+
102
+ An expression `:domain` will match if the domain in the value of `iss` matches the request
103
+ authority, excluding the most specific subdomain.
104
+
105
+ Issuer `https://accounts.example.com` matches request authorities `images.example.com`
106
+ and `sub.images.example.com`, but not `images.another.com`.
107
+
108
+ ```yaml
109
+ /images/:user-id:
110
+ auth:claim:
111
+ iss: :domain
112
+ sub: /:org-id
81
113
  ```
82
114
 
83
115
  ### `rule`
@@ -88,13 +120,10 @@ directives grant access. The value of the `rule` directive can be a single Rule
88
120
  #### Example
89
121
 
90
122
  ```yaml
91
- # context.toa.yaml
92
-
93
- exposition:
94
- /commits/:user-id:
95
- rule:
96
- id: user-id
97
- role: developer
123
+ /commits/:user-id:
124
+ rule:
125
+ id: user-id
126
+ role: developer
98
127
  ```
99
128
 
100
129
  Access will be granted if an Identity matches a `user-id` placeholder and has a Role of `developer`.
@@ -124,11 +153,8 @@ directive.
124
153
  #### Example
125
154
 
126
155
  ```yaml
127
- # context.toa.yaml
128
-
129
- /exposition:
130
- /commits/:user-id:
131
- role: developer:senior
156
+ /commits/:user-id:
157
+ role: developer:senior
132
158
  ```
133
159
 
134
160
  The example above defines a `role` directive with the specified `developer:senior` Role Scope.
@@ -17,13 +17,6 @@ exposition:
17
17
  two: the.two.com
18
18
  ```
19
19
 
20
- ## Ingress
21
-
22
- Each host in the authority definition is used to create a Kubernetes Ingress resource.
23
-
24
- > If the application is accessed with the `:authority` that does not match the authority definition,
25
- > the response with `404` status code is returned.
26
-
27
20
  ## Embedding
28
21
 
29
22
  To pass the requested authority to the operation call, [`vary:embed` directive](vary.md#embeddings)
@@ -40,6 +33,9 @@ exposition:
40
33
  endpoint: observe
41
34
  ```
42
35
 
36
+ If the value of the `authority` pseudo-header is not present in the `authorities` definition,
37
+ then the value of the `authority` pseudo-header is embedded as is.
38
+
43
39
  ## Identity
44
40
 
45
41
  Credentials stored or issued by the [authentication system](identity.md) are associated with an
@@ -56,6 +56,19 @@ as it provides a more specific match compared to the generic `/users/:id` route.
56
56
 
57
57
  The priority of Routes with the same specificity is determined by the order of declaration.
58
58
 
59
+ ## Route forwarding
60
+
61
+ A Route can be forwarded to another Route by specifying the destination Route as the value of the
62
+ Route.
63
+
64
+ ```yaml
65
+ /destination/:var: ...
66
+ /static: /destination/hello
67
+ /variables/:bar: /destination/:bar
68
+ ```
69
+
70
+ Forwarding Route variables are mapped to the forwarded Route variables if they have the same name.
71
+
59
72
  ## Methods
60
73
 
61
74
  Methods are mappings of the HTTP methods to the corresponding operations.
@@ -0,0 +1,170 @@
1
+ @security
2
+ Feature: Federated identity authentication
3
+
4
+ Background:
5
+ Given the `identity.federation` database is empty
6
+ And local IDP is running
7
+ And the IDP token for Bob is issued
8
+ And the `identity.federation` configuration:
9
+ """yaml
10
+ trust:
11
+ - iss: http://localhost:44444
12
+ """
13
+
14
+ Scenario: Full claim
15
+ Given the annotation:
16
+ """yaml
17
+ /:
18
+ GET:
19
+ auth:claim:
20
+ iss: http://localhost:44444
21
+ aud: test
22
+ sub: Bob
23
+ dev:stub: ok
24
+ """
25
+
26
+ When the following request is received:
27
+ """
28
+ GET / HTTP/1.1
29
+ host: nex.toa.io
30
+ authorization: Bearer ${{ Bob.id_token }}
31
+ """
32
+ Then the following reply is sent:
33
+ """
34
+ 200 OK
35
+ """
36
+
37
+ Scenario: Only `sub`
38
+ Given the annotation:
39
+ """yaml
40
+ /:
41
+ GET:
42
+ auth:claim:
43
+ sub: Bob
44
+ dev:stub: ok
45
+ """
46
+
47
+ When the following request is received:
48
+ """
49
+ GET / HTTP/1.1
50
+ host: nex.toa.io
51
+ authorization: Bearer ${{ Bob.id_token }}
52
+ """
53
+ Then the following reply is sent:
54
+ """
55
+ 200 OK
56
+ """
57
+
58
+ Scenario: No `sub`
59
+ Given the annotation:
60
+ """yaml
61
+ /:
62
+ GET:
63
+ auth:claim:
64
+ iss: http://localhost:44444
65
+ aud: test
66
+ dev:stub: ok
67
+ """
68
+
69
+ When the following request is received:
70
+ """
71
+ GET / HTTP/1.1
72
+ host: nex.toa.io
73
+ authorization: Bearer ${{ Bob.id_token }}
74
+ """
75
+ Then the following reply is sent:
76
+ """
77
+ 200 OK
78
+ """
79
+
80
+ Scenario: `sub` mismatch
81
+ Given the annotation:
82
+ """yaml
83
+ /:
84
+ GET:
85
+ auth:claim:
86
+ iss: http://localhost:44444
87
+ sub: Alice
88
+ dev:stub: ok
89
+ """
90
+
91
+ When the following request is received:
92
+ """
93
+ GET / HTTP/1.1
94
+ host: nex.toa.io
95
+ authorization: Bearer ${{ Bob.id_token }}
96
+ """
97
+ Then the following reply is sent:
98
+ """
99
+ 403 Forbidden
100
+ """
101
+
102
+ Scenario: `aud` mismatch
103
+ Given the annotation:
104
+ """yaml
105
+ /:
106
+ GET:
107
+ auth:claim:
108
+ iss: http://localhost:44444
109
+ aud: goalkeepers
110
+ dev:stub: ok
111
+ """
112
+
113
+ When the following request is received:
114
+ """
115
+ GET / HTTP/1.1
116
+ host: nex.toa.io
117
+ authorization: Bearer ${{ Bob.id_token }}
118
+ """
119
+ Then the following reply is sent:
120
+ """
121
+ 403 Forbidden
122
+ """
123
+
124
+ Scenario: Matching authority and Route parameter
125
+ Given the annotation:
126
+ """yaml
127
+ authorities:
128
+ test: the.test.local
129
+ /:
130
+ /:id:
131
+ GET:
132
+ auth:claim:
133
+ aud: :authority
134
+ sub: /:id
135
+ dev:stub: ok
136
+ """
137
+
138
+ When the following request is received:
139
+ """
140
+ GET /Bob/ HTTP/1.1
141
+ host: the.test.local
142
+ authorization: Bearer ${{ Bob.id_token }}
143
+ """
144
+ Then the following reply is sent:
145
+ """
146
+ 200 OK
147
+ """
148
+
149
+ Scenario: `iss` matching authority common domain
150
+ Given the annotation:
151
+ """yaml
152
+ /:
153
+ /:id:
154
+ GET:
155
+ auth:claim:
156
+ iss: :domain
157
+ sub: /:id
158
+ dev:stub: ok
159
+ """
160
+
161
+ When the following request is received:
162
+ """
163
+ GET /Bob/ HTTP/1.1
164
+ host: localhost
165
+ authorization: Bearer ${{ Bob.id_token }}
166
+ """
167
+ Then the following reply is sent:
168
+ """
169
+ 200 OK
170
+ """
@@ -19,6 +19,8 @@ Feature: Authorities
19
19
  """
20
20
  200 OK
21
21
  """
22
+
23
+ # arbitrary authorities are also allowed
22
24
  When the following request is received:
23
25
  """
24
26
  GET / HTTP/1.1
@@ -26,7 +28,5 @@ Feature: Authorities
26
28
  """
27
29
  Then the following reply is sent:
28
30
  """
29
- 404 Not Found
30
-
31
- Unknown authority
31
+ 200 OK
32
32
  """
@@ -5,7 +5,6 @@ Feature: OIDC tokens with authorities
5
5
  """yaml
6
6
  authorities:
7
7
  one: the.one.com
8
- two: the.two.com
9
8
  /:
10
9
  /:id:
11
10
  auth:id: id
@@ -65,6 +64,8 @@ Feature: OIDC tokens with authorities
65
64
  """
66
65
  200 OK
67
66
  """
67
+
68
+ # authorization will create new identity within `one` authority
68
69
  When the following request is received:
69
70
  """
70
71
  GET /${{ Two.id }}/ HTTP/1.1
@@ -73,7 +74,7 @@ Feature: OIDC tokens with authorities
73
74
  """
74
75
  Then the following reply is sent:
75
76
  """
76
- 401 Unauthorized
77
+ 403 Forbidden
77
78
  """
78
79
 
79
80
  # access `two` authority
@@ -85,7 +86,7 @@ Feature: OIDC tokens with authorities
85
86
  """
86
87
  Then the following reply is sent:
87
88
  """
88
- 401 Unauthorized
89
+ 403 Forbidden
89
90
  """
90
91
  When the following request is received:
91
92
  """
@@ -5,7 +5,6 @@ Feature: Token credentials with authorities
5
5
  """yaml
6
6
  authorities:
7
7
  one: the.one.com
8
- two: the.two.com
9
8
  /:
10
9
  /:id:
11
10
  auth:id: id
@@ -43,7 +42,7 @@ Feature: Token credentials with authorities
43
42
  id: ${{ one.id }}
44
43
  """
45
44
 
46
- # create identity within the `two` authority
45
+ # create identity within the `the.two.com` authority
47
46
  When the following request is received:
48
47
  """
49
48
  POST /identity/basic/ HTTP/1.1
@@ -3,7 +3,7 @@ Feature: Identity Federation
3
3
 
4
4
  Background:
5
5
  Given the `identity.federation` database is empty
6
- Given local IDP is running
6
+ And local IDP is running
7
7
 
8
8
  Scenario: Getting identity for a new user
9
9
  Given the `identity.federation` configuration:
@@ -169,7 +169,7 @@ Feature: Identity Federation
169
169
  - iss: http://localhost:44444
170
170
  principal:
171
171
  iss: http://localhost:44444
172
- sub: root-mock-id
172
+ sub: root
173
173
  """
174
174
  And the IDP token for root is issued
175
175
 
@@ -138,6 +138,32 @@ Feature: Routes
138
138
  201 Created
139
139
  """
140
140
 
141
+ Scenario: Routes with default namespace conflicts
142
+ Given the `echo` is running with the following manifest:
143
+ """yaml
144
+ exposition:
145
+ /:foo:
146
+ io:output: true
147
+ PUT: compute
148
+ """
149
+ And the `echo.beacon` is running with the following manifest:
150
+ """yaml
151
+ exposition:
152
+ /:
153
+ io:output: true
154
+ GET: hello
155
+ """
156
+ When the following request is received:
157
+ """
158
+ GET /echo/beacon/ HTTP/1.1
159
+ host: nex.toa.io
160
+ accept: application/yaml
161
+ """
162
+ Then the following reply is sent:
163
+ """
164
+ 200 OK
165
+ """
166
+
141
167
  Scenario: Routes with parameters
142
168
  Given the `echo` is running with the following manifest:
143
169
  """yaml
@@ -159,3 +185,40 @@ Feature: Routes
159
185
  a: foo
160
186
  b: bar
161
187
  """
188
+
189
+ Scenario: Route forwarding
190
+ Given the `echo` is running with the following manifest:
191
+ """yaml
192
+ exposition:
193
+ /show/:a/:b:
194
+ io:output: true
195
+ GET: parameters
196
+ /hello: /echo/show/foo/bar
197
+ /mirror/:a/:b: /echo/show/:a/:b
198
+ """
199
+ When the following request is received:
200
+ """
201
+ GET /echo/hello/ HTTP/1.1
202
+ host: nex.toa.io
203
+ accept: application/yaml
204
+ """
205
+ Then the following reply is sent:
206
+ """
207
+ 200 OK
208
+
209
+ a: foo
210
+ b: bar
211
+ """
212
+ When the following request is received:
213
+ """
214
+ GET /echo/mirror/bar/baz/ HTTP/1.1
215
+ host: nex.toa.io
216
+ accept: application/yaml
217
+ """
218
+ Then the following reply is sent:
219
+ """
220
+ 200 OK
221
+
222
+ a: bar
223
+ b: baz
224
+ """
@@ -109,7 +109,7 @@ export class IdP {
109
109
  },
110
110
  {
111
111
  iss: IdP.issuer,
112
- sub: `${user}-mock-id`,
112
+ sub: user,
113
113
  aud: 'test',
114
114
  iat: Math.floor(Date.now() / 1000),
115
115
  exp: Math.floor((Date.now() + 1000 * 60 * 5) / 1000)
@@ -134,7 +134,7 @@ export class IdP {
134
134
  },
135
135
  {
136
136
  iss: IdP.issuer,
137
- sub: `${user}-mock-id`,
137
+ sub: user,
138
138
  aud: 'test',
139
139
  iat: Math.floor(Date.now() / 1000),
140
140
  exp: Math.floor((Date.now() + 1000 * 60 * 5) / 1000)
@@ -0,0 +1,2 @@
1
+ namespace: echo
2
+ name: beacon
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ exports.computation = () => {
4
+ return 'Hello!'
5
+ }
@@ -214,7 +214,6 @@ Feature: The Vary directive family
214
214
  """yaml
215
215
  authorities:
216
216
  one: the.one.com
217
- two: the.two.com
218
217
  """
219
218
  Given the `echo` is running with the following manifest:
220
219
  """yaml
@@ -248,5 +247,5 @@ Feature: The Vary directive family
248
247
  """
249
248
  200 OK
250
249
 
251
- Hello two
250
+ Hello the.two.com
252
251
  """
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.49",
3
+ "version": "1.0.0-alpha.50",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -17,9 +17,9 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@toa.io/core": "1.0.0-alpha.49",
21
- "@toa.io/generic": "1.0.0-alpha.49",
22
- "@toa.io/schemas": "1.0.0-alpha.49",
20
+ "@toa.io/core": "1.0.0-alpha.50",
21
+ "@toa.io/generic": "1.0.0-alpha.50",
22
+ "@toa.io/schemas": "1.0.0-alpha.50",
23
23
  "bcryptjs": "2.4.3",
24
24
  "error-value": "0.3.0",
25
25
  "js-yaml": "4.1.0",
@@ -44,11 +44,11 @@
44
44
  "features:security": "cucumber-js --tags @security"
45
45
  },
46
46
  "devDependencies": {
47
- "@toa.io/agent": "1.0.0-alpha.49",
48
- "@toa.io/extensions.storages": "1.0.0-alpha.49",
47
+ "@toa.io/agent": "1.0.0-alpha.50",
48
+ "@toa.io/extensions.storages": "1.0.0-alpha.50",
49
49
  "@types/bcryptjs": "2.4.3",
50
50
  "@types/cors": "2.8.13",
51
51
  "@types/negotiator": "0.6.1"
52
52
  },
53
- "gitHead": "fb382cfd086bf867209a95046e25b92e43e5b2bb"
53
+ "gitHead": "514269d4b481150c8cd0db2e5971da6e9fe80ad9"
54
54
  }
@@ -1,5 +1,6 @@
1
1
  protected: boolean
2
2
  isolated: boolean
3
+ forward: string
3
4
  routes: [ref:route]
4
5
  methods: [ref:method]
5
6
  directives: [ref:directive]
package/source/Gateway.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import assert from 'node:assert'
1
2
  import { type bindings, Connector } from '@toa.io/core'
2
3
  import * as http from './HTTP'
3
4
  import { rethrow } from './exceptions'
4
5
  import type { Interception } from './Interception'
5
- import type { Node, Method, Parameter, Tree } from './RTD'
6
+ import type { Node, Method, Parameter, Tree, Match } from './RTD'
6
7
  import type { Label } from './discovery'
7
8
  import type { Branch } from './Branch'
8
9
 
@@ -28,12 +29,7 @@ export class Gateway extends Connector {
28
29
  if (interception !== null)
29
30
  return interception
30
31
 
31
- const match = this.tree.match(context.url.pathname)
32
-
33
- if (match === null)
34
- throw new http.NotFound('Route not found')
35
-
36
- const { node, parameters } = match
32
+ const { node, parameters } = this.match(context)
37
33
 
38
34
  if (context.request.method === 'OPTIONS')
39
35
  return await this.explain(node)
@@ -65,6 +61,31 @@ export class Gateway extends Connector {
65
61
  console.info('Gateway is closed')
66
62
  }
67
63
 
64
+ private match (context: http.Context): Match {
65
+ const match = this.tree.match(context.url.pathname)
66
+
67
+ if (match === null)
68
+ throw new http.NotFound('Route not found')
69
+
70
+ if (match.node.forward === null)
71
+ return match
72
+
73
+ const destination = match.node.forward.replace(/\/:([^/]+)/g,
74
+ (_, name) => {
75
+ const value = match.parameters.find((parameter) => parameter.name === name)?.value
76
+
77
+ assert.ok(value !== undefined, `Forwarded parameter '${name}' not found`)
78
+
79
+ return `/${value}`
80
+ })
81
+
82
+ const forward = this.tree.match(destination)
83
+
84
+ assert.ok(forward !== null, 'Forwarded route not found')
85
+
86
+ return forward
87
+ }
88
+
68
89
  private async call (method: Method, context: http.Context, parameters: Parameter[]): Promise<http.OutgoingMessage> {
69
90
  if (context.url.pathname[context.url.pathname.length - 1] !== '/')
70
91
  throw new http.NotFound('Trailing slash is required')
@@ -81,13 +81,8 @@ export class Server extends Connector {
81
81
  return
82
82
  }
83
83
 
84
- if (request.headers.host === undefined || !(request.headers.host in this.authorities)) {
85
- response.writeHead(404).end('Unknown authority')
86
-
87
- return
88
- }
89
-
90
- const authority = this.authorities[request.headers.host]
84
+ const host = request.headers.host!
85
+ const authority = this.authorities[host] ?? host
91
86
  const context = new Context(authority, request as IncomingMessage, this.properties)
92
87
 
93
88
  this.process!(context)
@@ -4,6 +4,7 @@ import { type Match, type Parameter } from './Match'
4
4
 
5
5
  export class Node {
6
6
  public intermediate: boolean
7
+ public forward: string | null
7
8
  public methods: Methods
8
9
  private readonly protected: boolean
9
10
  private routes: Route[]
@@ -13,6 +14,7 @@ export class Node {
13
14
  this.routes = routes
14
15
  this.methods = methods
15
16
  this.protected = properties.protected
17
+ this.forward = properties.forward ?? null
16
18
  this.intermediate = this.routes.findIndex((route) => route.root) !== -1
17
19
 
18
20
  this.sort()
@@ -76,10 +78,15 @@ export class Node {
76
78
  }
77
79
 
78
80
  private sort (): void {
79
- this.routes.sort((a, b) => a.variables - b.variables)
81
+ this.routes.sort((a, b) => {
82
+ return a.variables === b.variables
83
+ ? b.segments.length - a.segments.length // routes with more segments should be matched first
84
+ : a.variables - b.variables // routes with more variables should be matched last
85
+ })
80
86
  }
81
87
  }
82
88
 
83
89
  export interface Properties {
84
90
  protected: boolean
91
+ forward?: string
85
92
  }