node-opcua-alias-name-server 2.176.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 +22 -0
- package/README.md +361 -0
- package/dist/add_alias.d.ts +73 -0
- package/dist/add_alias.js +262 -0
- package/dist/add_alias.js.map +1 -0
- package/dist/address_space_alias_store.d.ts +130 -0
- package/dist/address_space_alias_store.js +360 -0
- package/dist/address_space_alias_store.js.map +1 -0
- package/dist/alias_hierarchy.d.ts +45 -0
- package/dist/alias_hierarchy.js +135 -0
- package/dist/alias_hierarchy.js.map +1 -0
- package/dist/alias_index.d.ts +60 -0
- package/dist/alias_index.js +119 -0
- package/dist/alias_index.js.map +1 -0
- package/dist/alias_name_archive.d.ts +45 -0
- package/dist/alias_name_archive.js +75 -0
- package/dist/alias_name_archive.js.map +1 -0
- package/dist/bind_alias_category.d.ts +173 -0
- package/dist/bind_alias_category.js +417 -0
- package/dist/bind_alias_category.js.map +1 -0
- package/dist/bind_configuration_methods.d.ts +44 -0
- package/dist/bind_configuration_methods.js +175 -0
- package/dist/bind_configuration_methods.js.map +1 -0
- package/dist/bind_find_alias.d.ts +61 -0
- package/dist/bind_find_alias.js +227 -0
- package/dist/bind_find_alias.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/install_alias_names.d.ts +229 -0
- package/dist/install_alias_names.js +146 -0
- package/dist/install_alias_names.js.map +1 -0
- package/dist/last_change.d.ts +87 -0
- package/dist/last_change.js +174 -0
- package/dist/last_change.js.map +1 -0
- package/dist/well_known.d.ts +88 -0
- package/dist/well_known.js +93 -0
- package/dist/well_known.js.map +1 -0
- package/package.json +54 -0
- package/source/add_alias.ts +318 -0
- package/source/address_space_alias_store.ts +417 -0
- package/source/alias_hierarchy.ts +141 -0
- package/source/alias_index.ts +127 -0
- package/source/alias_name_archive.ts +84 -0
- package/source/bind_alias_category.ts +546 -0
- package/source/bind_configuration_methods.ts +219 -0
- package/source/bind_find_alias.ts +290 -0
- package/source/index.ts +74 -0
- package/source/install_alias_names.ts +342 -0
- package/source/last_change.ts +201 -0
- package/source/well_known.ts +101 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* `AddAliasesToCategory` (clause 6.3.4) and `DeleteAliasesFromCategory`
|
|
5
|
+
* (clause 6.3.5) — the *AliasName Configuration Support* facet, CU 5874.
|
|
6
|
+
*
|
|
7
|
+
* Both report **per item**: the Method itself succeeds and an `ErrorCodes` array
|
|
8
|
+
* parallel to `AliasNames` says what happened to each one. A single bad entry
|
|
9
|
+
* does not fail the call, which is why the store interface returns
|
|
10
|
+
* `StatusCode[]` rather than throwing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ISessionContext, UAMethod, UAObject } from "node-opcua-address-space-base";
|
|
14
|
+
import type { AliasEntry, IAliasStore } from "node-opcua-alias-name-common";
|
|
15
|
+
import { type ExpandedNodeId, type NodeId, NodeId as NodeIdClass } from "node-opcua-nodeid";
|
|
16
|
+
import type { CallMethodResultOptions } from "node-opcua-service-call";
|
|
17
|
+
import { type StatusCode, StatusCodes } from "node-opcua-status-code";
|
|
18
|
+
import { DataType, type Variant, VariantArrayType } from "node-opcua-variant";
|
|
19
|
+
import { ALIAS_FOR } from "./well_known.js";
|
|
20
|
+
|
|
21
|
+
export interface ConfigurationBindingOptions {
|
|
22
|
+
/** The store that performs the mutation. Must implement `add` / `delete`. */
|
|
23
|
+
store: IAliasStore;
|
|
24
|
+
/**
|
|
25
|
+
* Write gate (clause 6.3.4 Table 11 / 6.3.5 Table 15). **Defaults to denying
|
|
26
|
+
* everyone** — the write surface is the one place a permissive default would
|
|
27
|
+
* be a security defect rather than a convenience.
|
|
28
|
+
*/
|
|
29
|
+
isWriteAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
30
|
+
/** Called after a successful mutation so `LastChange` can move. */
|
|
31
|
+
onChanged?: (categoryNodeId: NodeId) => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a String[] argument, tolerating null for "empty". */
|
|
35
|
+
function readStringArray(inputArguments: Variant[], index: number): string[] | null {
|
|
36
|
+
const value = inputArguments?.[index]?.value;
|
|
37
|
+
if (value === null || value === undefined) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return value.map((v) => (typeof v === "string" ? v : String(v)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read an ExpandedNodeId[] argument, tolerating null for "empty". */
|
|
47
|
+
function readNodeIdArray(inputArguments: Variant[], index: number): ExpandedNodeId[] | null {
|
|
48
|
+
const value = inputArguments?.[index]?.value;
|
|
49
|
+
if (value === null || value === undefined) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(value)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return value as ExpandedNodeId[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build the OutputArguments Variant for an ErrorCodes array. */
|
|
59
|
+
function errorCodesResult(codes: StatusCode[]): CallMethodResultOptions {
|
|
60
|
+
return {
|
|
61
|
+
statusCode: StatusCodes.Good,
|
|
62
|
+
outputArguments: [
|
|
63
|
+
{
|
|
64
|
+
dataType: DataType.StatusCode,
|
|
65
|
+
arrayType: VariantArrayType.Array,
|
|
66
|
+
value: codes
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handler for `AddAliasesToCategory` (clause 6.3.4).
|
|
74
|
+
*
|
|
75
|
+
* Table 11 governs the *call*: `Bad_InvalidArgument` when an argument is the
|
|
76
|
+
* wrong type, when the arrays other than `TargetServers` differ in length, or
|
|
77
|
+
* when all arrays are empty. Table 10 governs each *item*.
|
|
78
|
+
*/
|
|
79
|
+
export function makeAddAliasesToCategoryHandler(options: ConfigurationBindingOptions) {
|
|
80
|
+
return async function addAliasesToCategoryHandler(
|
|
81
|
+
this: UAMethod,
|
|
82
|
+
inputArguments: Variant[],
|
|
83
|
+
context: ISessionContext
|
|
84
|
+
): Promise<CallMethodResultOptions> {
|
|
85
|
+
const category = this.parent as UAObject | null;
|
|
86
|
+
if (!category) {
|
|
87
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// clause 6.3.4 Table 11
|
|
91
|
+
if (!(await isWriteAllowed(options, context, category.nodeId))) {
|
|
92
|
+
return { statusCode: StatusCodes.BadUserAccessDenied };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const aliasNames = readStringArray(inputArguments, 0);
|
|
96
|
+
const targetNodes = readNodeIdArray(inputArguments, 1);
|
|
97
|
+
const targetServers = readStringArray(inputArguments, 2);
|
|
98
|
+
const targetReferenceTypeRaw = inputArguments?.[3]?.value;
|
|
99
|
+
|
|
100
|
+
if (aliasNames === null || targetNodes === null || targetServers === null) {
|
|
101
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
102
|
+
}
|
|
103
|
+
// "the size of the arrays for all arguments except TargetServers is not
|
|
104
|
+
// the same" - TargetServers is excluded because Table 9 lets it be null
|
|
105
|
+
// or empty to mean "all local"
|
|
106
|
+
if (aliasNames.length !== targetNodes.length) {
|
|
107
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
108
|
+
}
|
|
109
|
+
// "or if all arrays are empty"
|
|
110
|
+
if (aliasNames.length === 0) {
|
|
111
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
112
|
+
}
|
|
113
|
+
if (targetServers.length !== 0 && targetServers.length !== aliasNames.length) {
|
|
114
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Table 9: "If null, it defaults to AliasFor."
|
|
118
|
+
const referenceTypeId =
|
|
119
|
+
targetReferenceTypeRaw instanceof NodeIdClass && !targetReferenceTypeRaw.isEmpty() ? targetReferenceTypeRaw : ALIAS_FOR;
|
|
120
|
+
|
|
121
|
+
const entries: AliasEntry[] = aliasNames.map((aliasName, i) => {
|
|
122
|
+
// Table 9: "The ServerIndex in the ExpandedNodeId shall be ignored
|
|
123
|
+
// and the TargetServers Uri shall be used."
|
|
124
|
+
const serverUri = targetServers[i] ? targetServers[i] : null;
|
|
125
|
+
return {
|
|
126
|
+
aliasName,
|
|
127
|
+
referencedNodes: [targetNodes[i]],
|
|
128
|
+
serverUris: [serverUri],
|
|
129
|
+
categoryNodeId: category.nodeId,
|
|
130
|
+
referenceTypeIds: [referenceTypeId]
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!options.store.add) {
|
|
135
|
+
// a read-only store: every item is unsupported, but the call itself
|
|
136
|
+
// succeeded in reporting that
|
|
137
|
+
return errorCodesResult(entries.map(() => StatusCodes.BadNotSupported));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const codes = await options.store.add(category.nodeId, entries);
|
|
141
|
+
if (codes.some((code) => code.isGood())) {
|
|
142
|
+
await options.onChanged?.(category.nodeId);
|
|
143
|
+
}
|
|
144
|
+
return errorCodesResult(codes);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handler for `DeleteAliasesFromCategory` (clause 6.3.5).
|
|
150
|
+
*
|
|
151
|
+
* Table 15 governs the call: unlike Add, **every** array must be the same
|
|
152
|
+
* length — "the size of the arrays for all arguments is not the same", with no
|
|
153
|
+
* exception, because there is no `TargetServers` here.
|
|
154
|
+
*/
|
|
155
|
+
export function makeDeleteAliasesFromCategoryHandler(options: ConfigurationBindingOptions) {
|
|
156
|
+
return async function deleteAliasesFromCategoryHandler(
|
|
157
|
+
this: UAMethod,
|
|
158
|
+
inputArguments: Variant[],
|
|
159
|
+
context: ISessionContext
|
|
160
|
+
): Promise<CallMethodResultOptions> {
|
|
161
|
+
const category = this.parent as UAObject | null;
|
|
162
|
+
if (!category) {
|
|
163
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!(await isWriteAllowed(options, context, category.nodeId))) {
|
|
167
|
+
return { statusCode: StatusCodes.BadUserAccessDenied };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const aliasNames = readStringArray(inputArguments, 0);
|
|
171
|
+
const targetNodes = readNodeIdArray(inputArguments, 1);
|
|
172
|
+
|
|
173
|
+
if (aliasNames === null || targetNodes === null) {
|
|
174
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
175
|
+
}
|
|
176
|
+
if (aliasNames.length === 0) {
|
|
177
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
178
|
+
}
|
|
179
|
+
// Table 13: "The length of each of the arrays shall be the same." A null
|
|
180
|
+
// TargetNodes array is the documented way to say "every target", so an
|
|
181
|
+
// empty array is accepted and expanded.
|
|
182
|
+
if (targetNodes.length !== 0 && targetNodes.length !== aliasNames.length) {
|
|
183
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const entries = aliasNames.map((aliasName, i) => {
|
|
187
|
+
// "If the TargetNodes array entry is null or empty, all AliasNames
|
|
188
|
+
// with the provided name are deleted from the AliasNameCategory."
|
|
189
|
+
const target = targetNodes[i];
|
|
190
|
+
const referencedNodes = target && !target.isEmpty() ? [target] : [];
|
|
191
|
+
return { aliasName, referencedNodes };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!options.store.delete) {
|
|
195
|
+
return errorCodesResult(entries.map(() => StatusCodes.BadNotSupported));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const codes = await options.store.delete(category.nodeId, entries);
|
|
199
|
+
if (codes.some((code) => code.isGood())) {
|
|
200
|
+
await options.onChanged?.(category.nodeId);
|
|
201
|
+
}
|
|
202
|
+
return errorCodesResult(codes);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The write gate, defaulting to deny. */
|
|
207
|
+
async function isWriteAllowed(
|
|
208
|
+
options: ConfigurationBindingOptions,
|
|
209
|
+
context: ISessionContext,
|
|
210
|
+
categoryNodeId: NodeId
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
if (!options.isWriteAllowed) {
|
|
213
|
+
// Writing is off unless the Server says who may do it. OPC 10000-17
|
|
214
|
+
// defines no security model at all, so there is no safe default rule to
|
|
215
|
+
// fall back on - only a safe default answer.
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return await options.isWriteAllowed(context, categoryNodeId);
|
|
219
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* The `FindAlias` (clause 6.3.2) and `FindAliasVerbose` (clause 6.3.3) Method
|
|
5
|
+
* handlers.
|
|
6
|
+
*
|
|
7
|
+
* The two Methods are identical in every respect except the DataType they
|
|
8
|
+
* return, so both are produced from one implementation: whatever the FindAlias
|
|
9
|
+
* suite proves is proved for FindAliasVerbose too.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ISessionContext, UAMethod, UAObject } from "node-opcua-address-space-base";
|
|
13
|
+
import { type AliasEntry, type AliasQuery, type IAliasStore, InvalidLikePatternError } from "node-opcua-alias-name-common";
|
|
14
|
+
import { type NodeId, NodeId as NodeIdClass } from "node-opcua-nodeid";
|
|
15
|
+
import type { CallMethodResultOptions } from "node-opcua-service-call";
|
|
16
|
+
import { StatusCodes } from "node-opcua-status-code";
|
|
17
|
+
import { AliasNameDataType, AliasNameVerboseDataType } from "node-opcua-types";
|
|
18
|
+
import { DataType, type Variant, VariantArrayType } from "node-opcua-variant";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Orders results before they are returned.
|
|
22
|
+
*
|
|
23
|
+
* Clause 6.3.2 requires the Server to return "what it recommends as the best
|
|
24
|
+
* match first", and says the criteria are Server specific — the examples it
|
|
25
|
+
* gives (ServerStatus of the Server holding the Node, load balancing) only mean
|
|
26
|
+
* anything once more than one Server is involved. A Server publishing its own
|
|
27
|
+
* aliases has no basis to prefer one of its own Nodes over another, so the
|
|
28
|
+
* default preserves discovery order, which is at least deterministic and
|
|
29
|
+
* therefore stable across calls. Replace it when the Server does have a basis.
|
|
30
|
+
*/
|
|
31
|
+
export type AliasComparator = (a: AliasEntry, b: AliasEntry) => number;
|
|
32
|
+
|
|
33
|
+
/** The default: keep discovery order (a stable no-op comparator). */
|
|
34
|
+
export const insertionOrderComparator: AliasComparator = () => 0;
|
|
35
|
+
|
|
36
|
+
export interface FindAliasBindingOptions {
|
|
37
|
+
/** Where the aliases come from. */
|
|
38
|
+
store: IAliasStore;
|
|
39
|
+
/**
|
|
40
|
+
* Beyond this many results the call fails with `Bad_ResponseTooLarge`
|
|
41
|
+
* (clause 6.3.2 Table 4).
|
|
42
|
+
*/
|
|
43
|
+
maxResults: number;
|
|
44
|
+
/** Result ordering; defaults to {@link insertionOrderComparator}. */
|
|
45
|
+
comparator?: AliasComparator;
|
|
46
|
+
/**
|
|
47
|
+
* Read gate. Return false to answer `Bad_UserAccessDenied`
|
|
48
|
+
* (clause 6.3.2 Table 4). Defaults to allowing everyone, matching a Server
|
|
49
|
+
* that publishes its aliases openly.
|
|
50
|
+
*
|
|
51
|
+
* Receives the category the Method was called on, so a Server with
|
|
52
|
+
* per-customer or per-tenant categories can answer "may this user see *this*
|
|
53
|
+
* category" rather than only "may this user read aliases at all". May return
|
|
54
|
+
* a Promise: the handler is async anyway, so a permission lookup that hits a
|
|
55
|
+
* database costs nothing structurally.
|
|
56
|
+
*/
|
|
57
|
+
isReadAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read `AliasNameSearchPattern` (argument 0). */
|
|
61
|
+
function readPattern(inputArguments: Variant[]): string | null {
|
|
62
|
+
const value = inputArguments?.[0]?.value;
|
|
63
|
+
if (value === null || value === undefined) {
|
|
64
|
+
// clause 6.3.2 gives no meaning to a null pattern; treat it as "match
|
|
65
|
+
// everything" would silently dump the whole hierarchy, so reject it
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return typeof value === "string" ? value : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read `ReferenceTypeFilter` (argument 1). */
|
|
72
|
+
function readReferenceTypeFilter(inputArguments: Variant[]): NodeId | undefined {
|
|
73
|
+
const value = inputArguments?.[1]?.value;
|
|
74
|
+
return value instanceof NodeIdClass ? value : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Merge entries that share an AliasName.
|
|
79
|
+
*
|
|
80
|
+
* `AliasNameDataType` is "an array of ExpandedNodeId for a single AliasName"
|
|
81
|
+
* (clause 7.2), so the non-verbose Method reports one entry per distinct name
|
|
82
|
+
* with all of its targets. The verbose form does *not* merge: its
|
|
83
|
+
* `AliasNameCategoryId` names the category that held the alias, which differs
|
|
84
|
+
* between entries.
|
|
85
|
+
*/
|
|
86
|
+
function mergeByAliasName(entries: AliasEntry[]): AliasEntry[] {
|
|
87
|
+
const byName = new Map<string, AliasEntry>();
|
|
88
|
+
// Membership is kept in a Set per name rather than rescanning the growing
|
|
89
|
+
// array: with a linear scan, a name shared by many entries makes this
|
|
90
|
+
// quadratic in the size of the result set.
|
|
91
|
+
const seenTargets = new Map<string, Set<string>>();
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const existing = byName.get(entry.aliasName);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
byName.set(entry.aliasName, { ...entry, referencedNodes: [...entry.referencedNodes] });
|
|
97
|
+
seenTargets.set(entry.aliasName, new Set(entry.referencedNodes.map((n) => n.toString())));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// written alongside byName above, so it is always present
|
|
101
|
+
const seen = seenTargets.get(entry.aliasName) ?? new Set<string>();
|
|
102
|
+
seenTargets.set(entry.aliasName, seen);
|
|
103
|
+
for (const node of entry.referencedNodes) {
|
|
104
|
+
const key = node.toString();
|
|
105
|
+
if (!seen.has(key)) {
|
|
106
|
+
seen.add(key);
|
|
107
|
+
existing.referencedNodes.push(node);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...byName.values()];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve each entry's AliasName namespace index, once per call.
|
|
116
|
+
*
|
|
117
|
+
* The namespace reported is the one the alias was published in, taken from the
|
|
118
|
+
* store. Using the *category's* namespace instead would put every alias on the
|
|
119
|
+
* three well-known categories into namespace 0, which is reserved for the OPC
|
|
120
|
+
* Foundation and is not what clause 6.2 intends. A store that does not know its
|
|
121
|
+
* namespace falls back to the Server's own, never to 0.
|
|
122
|
+
*
|
|
123
|
+
* Clients ignore the namespace when comparing AliasNames (clause 6.2), so this
|
|
124
|
+
* is not a matching concern — it is a matter of reporting truthfully.
|
|
125
|
+
*/
|
|
126
|
+
function makeNamespaceResolver(category: UAObject): (entry: AliasEntry) => number {
|
|
127
|
+
const addressSpace = category.addressSpace;
|
|
128
|
+
const ownNamespaceIndex = addressSpace.getOwnNamespace().index;
|
|
129
|
+
const cache = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
return (entry: AliasEntry): number => {
|
|
132
|
+
if (!entry.aliasNameNamespaceUri) {
|
|
133
|
+
return ownNamespaceIndex;
|
|
134
|
+
}
|
|
135
|
+
const cached = cache.get(entry.aliasNameNamespaceUri);
|
|
136
|
+
if (cached !== undefined) {
|
|
137
|
+
return cached;
|
|
138
|
+
}
|
|
139
|
+
const index = addressSpace.getNamespaceIndex(entry.aliasNameNamespaceUri);
|
|
140
|
+
// an unregistered URI resolves to -1; reporting the Server's own
|
|
141
|
+
// namespace is closer to the truth than reporting namespace 0
|
|
142
|
+
const resolved = index >= 0 ? index : ownNamespaceIndex;
|
|
143
|
+
cache.set(entry.aliasNameNamespaceUri, resolved);
|
|
144
|
+
return resolved;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a handler for `FindAlias` or `FindAliasVerbose`.
|
|
150
|
+
*
|
|
151
|
+
* `this` is the Method node, so the category searched is the Method's parent —
|
|
152
|
+
* that is what makes one handler serve every instance of
|
|
153
|
+
* `AliasNameCategoryType`, including vendor subcategories created through the
|
|
154
|
+
* `<SubAliasNameCategories>` placeholder.
|
|
155
|
+
*/
|
|
156
|
+
export function makeFindAliasHandler(options: FindAliasBindingOptions, verbose: boolean) {
|
|
157
|
+
const comparator = options.comparator ?? insertionOrderComparator;
|
|
158
|
+
|
|
159
|
+
return async function findAliasHandler(
|
|
160
|
+
this: UAMethod,
|
|
161
|
+
inputArguments: Variant[],
|
|
162
|
+
context: ISessionContext
|
|
163
|
+
): Promise<CallMethodResultOptions> {
|
|
164
|
+
const category = this.parent as UAObject | null;
|
|
165
|
+
if (!category) {
|
|
166
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The gate is consulted per category, and each category at most once per
|
|
170
|
+
// call. Memoised because a recursive search reaches the same category
|
|
171
|
+
// through every alias it holds, and the rule may hit a database.
|
|
172
|
+
const gate = options.isReadAllowed;
|
|
173
|
+
const decisions = new Map<string, boolean>();
|
|
174
|
+
const mayRead = async (categoryNodeId: NodeId): Promise<boolean> => {
|
|
175
|
+
if (!gate) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const key = categoryNodeId.toString();
|
|
179
|
+
const cached = decisions.get(key);
|
|
180
|
+
if (cached !== undefined) {
|
|
181
|
+
return cached;
|
|
182
|
+
}
|
|
183
|
+
const allowed = await gate(context, categoryNodeId);
|
|
184
|
+
decisions.set(key, allowed);
|
|
185
|
+
return allowed;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// A direct call on a category the caller may not read is
|
|
189
|
+
// Bad_UserAccessDenied: there is nothing left to filter, so silence
|
|
190
|
+
// would be a lie rather than a non-disclosure.
|
|
191
|
+
if (!(await mayRead(category.nodeId))) {
|
|
192
|
+
return { statusCode: StatusCodes.BadUserAccessDenied };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pattern = readPattern(inputArguments);
|
|
196
|
+
if (pattern === null) {
|
|
197
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const query: AliasQuery = {
|
|
201
|
+
pattern,
|
|
202
|
+
referenceTypeFilter: readReferenceTypeFilter(inputArguments),
|
|
203
|
+
categoryNodeId: category.nodeId,
|
|
204
|
+
maxResults: options.maxResults,
|
|
205
|
+
// Handed the same memoised closure the filter below uses, so the
|
|
206
|
+
// rule is evaluated at most once per category per call however many
|
|
207
|
+
// times it is consulted. Passed only when a gate is configured, so
|
|
208
|
+
// an ungated Server takes exactly the path it took before.
|
|
209
|
+
isVisible: gate ? mayRead : undefined
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
let found: AliasEntry[];
|
|
213
|
+
try {
|
|
214
|
+
found = await options.store.find(query);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err instanceof InvalidLikePatternError) {
|
|
217
|
+
// clause 6.3.2 Table 4: "The input string is not a valid search string"
|
|
218
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
219
|
+
}
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// clause 6.3.2 Table 4: too large to return, "try new filter and repeat find".
|
|
224
|
+
//
|
|
225
|
+
// Applied to what the store produced, *before* filtering and merging.
|
|
226
|
+
// The store stops collecting one entry past the cap, so a count that
|
|
227
|
+
// reaches it means "there may be more"; reducing the count first would
|
|
228
|
+
// report a truncated scan as a complete answer. The code names no
|
|
229
|
+
// category, so it discloses nothing a gated caller should not see.
|
|
230
|
+
if (found.length > options.maxResults) {
|
|
231
|
+
return { statusCode: StatusCodes.BadResponseTooLarge };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A nested category the caller may not read is omitted, and the call
|
|
235
|
+
// still succeeds: an error, or a count that changed, would confirm the
|
|
236
|
+
// category exists. Absence is the only answer that discloses nothing.
|
|
237
|
+
//
|
|
238
|
+
// The store was given the same predicate and should already have skipped
|
|
239
|
+
// these, so this is normally a no-op. It stays as a backstop: an injected
|
|
240
|
+
// store written by someone else may ignore `isVisible`, and the cost of
|
|
241
|
+
// that must be a wasted scan, never a leak.
|
|
242
|
+
const visible: AliasEntry[] = [];
|
|
243
|
+
for (const entry of found) {
|
|
244
|
+
if (await mayRead(entry.categoryNodeId)) {
|
|
245
|
+
visible.push(entry);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Filtering happens before the verbose/plain split, so FindAliasVerbose
|
|
250
|
+
// cannot reveal a ServerUri or an AliasNameCategoryId for a category
|
|
251
|
+
// that FindAlias would have hidden.
|
|
252
|
+
const results = verbose ? visible : mergeByAliasName(visible);
|
|
253
|
+
|
|
254
|
+
// sort() is stable in modern JavaScript, so a comparator that returns 0
|
|
255
|
+
// leaves discovery order untouched
|
|
256
|
+
const ordered = [...results].sort(comparator);
|
|
257
|
+
|
|
258
|
+
// No match is Good with an empty AliasNodeList (clause 6.3.2 Table 3),
|
|
259
|
+
// never an error.
|
|
260
|
+
const namespaceIndexOf = makeNamespaceResolver(category);
|
|
261
|
+
const value = verbose
|
|
262
|
+
? ordered.map(
|
|
263
|
+
(entry) =>
|
|
264
|
+
new AliasNameVerboseDataType({
|
|
265
|
+
aliasName: { name: entry.aliasName, namespaceIndex: namespaceIndexOf(entry) },
|
|
266
|
+
referencedNodes: entry.referencedNodes,
|
|
267
|
+
serverUris: entry.serverUris,
|
|
268
|
+
aliasNameCategoryId: entry.categoryNodeId
|
|
269
|
+
})
|
|
270
|
+
)
|
|
271
|
+
: ordered.map(
|
|
272
|
+
(entry) =>
|
|
273
|
+
new AliasNameDataType({
|
|
274
|
+
aliasName: { name: entry.aliasName, namespaceIndex: namespaceIndexOf(entry) },
|
|
275
|
+
referencedNodes: entry.referencedNodes
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
statusCode: StatusCodes.Good,
|
|
281
|
+
outputArguments: [
|
|
282
|
+
{
|
|
283
|
+
dataType: DataType.ExtensionObject,
|
|
284
|
+
arrayType: VariantArrayType.Array,
|
|
285
|
+
value
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
}
|
package/source/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* Server-side OPC 10000-17 (AliasNames).
|
|
5
|
+
*
|
|
6
|
+
* Publishes **this Server's own** AliasNames. Aggregating AliasNames collected
|
|
7
|
+
* from other Servers (Annex B, Annex C) and the Annex D PubSub change
|
|
8
|
+
* notification are out of scope.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { type AddAliasOptions, AliasNameError, addAlias, findAlias, removeAlias } from "./add_alias.js";
|
|
12
|
+
export { AddressSpaceAliasStore, type AddressSpaceAliasStoreOptions } from "./address_space_alias_store.js";
|
|
13
|
+
export {
|
|
14
|
+
aliasesOf,
|
|
15
|
+
collectAllCategories,
|
|
16
|
+
collectCategories,
|
|
17
|
+
findAliasNameCategoryType,
|
|
18
|
+
findAliasNameType,
|
|
19
|
+
isAliasName,
|
|
20
|
+
isAliasNameCategory,
|
|
21
|
+
presentWellKnownCategories
|
|
22
|
+
} from "./alias_hierarchy.js";
|
|
23
|
+
export {
|
|
24
|
+
ALIAS_NAME_ARCHIVE_VERSION,
|
|
25
|
+
type AliasNameArchive,
|
|
26
|
+
readAliasNameArchive,
|
|
27
|
+
writeAliasNameArchive
|
|
28
|
+
} from "./alias_name_archive.js";
|
|
29
|
+
export {
|
|
30
|
+
type AddAliasCategoryOptions,
|
|
31
|
+
addAliasCategory,
|
|
32
|
+
type BindAliasCategoryOptions,
|
|
33
|
+
bindAliasCategory,
|
|
34
|
+
ensureLastChangeProperty,
|
|
35
|
+
ensureOptionalMethod,
|
|
36
|
+
findMethodByDeclaration,
|
|
37
|
+
getInstalledAliasNames,
|
|
38
|
+
type InstalledAliasNames,
|
|
39
|
+
removeAliasCategory
|
|
40
|
+
} from "./bind_alias_category.js";
|
|
41
|
+
export {
|
|
42
|
+
type AliasComparator,
|
|
43
|
+
type FindAliasBindingOptions,
|
|
44
|
+
insertionOrderComparator,
|
|
45
|
+
makeFindAliasHandler
|
|
46
|
+
} from "./bind_find_alias.js";
|
|
47
|
+
export {
|
|
48
|
+
ALIAS_SERVER_CAPABILITY_ID,
|
|
49
|
+
advertiseAliasCapability,
|
|
50
|
+
type CategoryProvider,
|
|
51
|
+
DEFAULT_MAX_RESULTS,
|
|
52
|
+
defaultCategoryProvider,
|
|
53
|
+
type InstallAliasNamesOptions,
|
|
54
|
+
type InstallAliasNamesResult,
|
|
55
|
+
type IServerForAliasNames,
|
|
56
|
+
installAliasNames,
|
|
57
|
+
installAliasNamesOnAddressSpace
|
|
58
|
+
} from "./install_alias_names.js";
|
|
59
|
+
export {
|
|
60
|
+
LAST_CHANGE_BROWSE_NAME,
|
|
61
|
+
LastChangeTracker,
|
|
62
|
+
type LastChangeTrackerOptions
|
|
63
|
+
} from "./last_change.js";
|
|
64
|
+
export {
|
|
65
|
+
ALIAS_FOR,
|
|
66
|
+
ALIAS_NAME_CATEGORY_TYPE,
|
|
67
|
+
ALIAS_NAME_TYPE,
|
|
68
|
+
ALIAS_SERVER_CAPABILITY,
|
|
69
|
+
MethodDeclarations,
|
|
70
|
+
PUBLISHED_DATA_SET_TYPE,
|
|
71
|
+
WellKnownCategories,
|
|
72
|
+
WellKnownLastChange,
|
|
73
|
+
WellKnownOptionalMethods
|
|
74
|
+
} from "./well_known.js";
|