@uns-kit/api 2.0.24 → 2.0.26
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 +21 -21
- package/README.md +77 -77
- package/dist/api-interfaces.d.ts +1 -1
- package/dist/app.d.ts +48 -48
- package/dist/app.js +150 -150
- package/dist/uns-api-plugin.js +52 -52
- package/dist/uns-api-proxy.d.ts +58 -58
- package/dist/uns-api-proxy.js +514 -514
- package/package.json +2 -2
package/dist/uns-api-proxy.js
CHANGED
|
@@ -1,514 +1,514 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
2
|
-
import jwt from "jsonwebtoken";
|
|
3
|
-
import * as path from "path";
|
|
4
|
-
import { createPublicKey } from "crypto";
|
|
5
|
-
import { basePath } from "@uns-kit/core/base-path.js";
|
|
6
|
-
import { UnsAttributeType } from "@uns-kit/core/graphql/schema.js";
|
|
7
|
-
import logger from "@uns-kit/core/logger.js";
|
|
8
|
-
import { MqttTopicBuilder } from "@uns-kit/core/uns-mqtt/mqtt-topic-builder.js";
|
|
9
|
-
import { UnsPacket } from "@uns-kit/core/uns/uns-packet.js";
|
|
10
|
-
import UnsProxy from "@uns-kit/core/uns/uns-proxy.js";
|
|
11
|
-
import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher.js";
|
|
12
|
-
import { DataSizeMeasurements, PhysicalMeasurements } from "@uns-kit/core/uns/uns-measurements.js";
|
|
13
|
-
import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
|
|
14
|
-
import App from "./app.js";
|
|
15
|
-
const packageJsonPath = path.join(basePath, "package.json");
|
|
16
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
17
|
-
const normalizeBasePrefix = (value) => {
|
|
18
|
-
if (!value)
|
|
19
|
-
return "";
|
|
20
|
-
const trimmed = value.trim();
|
|
21
|
-
if (!trimmed)
|
|
22
|
-
return "";
|
|
23
|
-
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
24
|
-
return withLeading.replace(/\/+$/, "");
|
|
25
|
-
};
|
|
26
|
-
const buildSwaggerPath = (base, processName, instanceName) => {
|
|
27
|
-
const processSegment = `/${processName}`;
|
|
28
|
-
let baseWithProcess = base || "/";
|
|
29
|
-
if (!baseWithProcess.endsWith(processSegment)) {
|
|
30
|
-
baseWithProcess = `${baseWithProcess}${processSegment}`;
|
|
31
|
-
}
|
|
32
|
-
return `${baseWithProcess}/${instanceName}/swagger.json`.replace(/\/{2,}/g, "/");
|
|
33
|
-
};
|
|
34
|
-
export default class UnsApiProxy extends UnsProxy {
|
|
35
|
-
instanceName;
|
|
36
|
-
topicBuilder;
|
|
37
|
-
processName;
|
|
38
|
-
processStatusTopic;
|
|
39
|
-
app;
|
|
40
|
-
options;
|
|
41
|
-
apiBasePrefix;
|
|
42
|
-
swaggerBasePrefix;
|
|
43
|
-
jwksCache;
|
|
44
|
-
catchAllRouteRegistered = false;
|
|
45
|
-
startedAt;
|
|
46
|
-
statusInterval = null;
|
|
47
|
-
statusIntervalMs = 10_000;
|
|
48
|
-
constructor(processName, instanceName, options) {
|
|
49
|
-
super();
|
|
50
|
-
this.options = options;
|
|
51
|
-
this.apiBasePrefix =
|
|
52
|
-
normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
|
|
53
|
-
const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
|
|
54
|
-
this.swaggerBasePrefix = rawSwaggerBase.endsWith("/api")
|
|
55
|
-
? rawSwaggerBase.replace(/\/api\/?$/, "") || "/"
|
|
56
|
-
: rawSwaggerBase;
|
|
57
|
-
this.app = new App(0, processName, instanceName, undefined, {
|
|
58
|
-
apiBasePrefix: this.apiBasePrefix,
|
|
59
|
-
swaggerBasePrefix: this.swaggerBasePrefix,
|
|
60
|
-
disableDefaultApiMount: options.disableDefaultApiMount ?? false,
|
|
61
|
-
});
|
|
62
|
-
this.app.start();
|
|
63
|
-
this.startedAt = Date.now();
|
|
64
|
-
this.instanceName = instanceName;
|
|
65
|
-
this.processName = processName;
|
|
66
|
-
// Create the topic builder using packageJson values and the processName.
|
|
67
|
-
this.topicBuilder = new MqttTopicBuilder(`uns-infra/${MqttTopicBuilder.sanitizeTopicPart(packageJson.name)}/${MqttTopicBuilder.sanitizeTopicPart(packageJson.version)}/${MqttTopicBuilder.sanitizeTopicPart(processName)}/`);
|
|
68
|
-
// Generate the processStatusTopic using the builder.
|
|
69
|
-
this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
|
|
70
|
-
// Derive the instanceStatusTopic by appending the instance name.
|
|
71
|
-
this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
|
|
72
|
-
// Concatenate processName with instanceName for the worker identification.
|
|
73
|
-
this.instanceNameWithSuffix = `${processName}-${instanceName}`;
|
|
74
|
-
this.registerHealthEndpoint();
|
|
75
|
-
// Emit once after listeners are attached in the plugin, then on the regular cadence.
|
|
76
|
-
setTimeout(() => this.emitStatusMetrics(), 0);
|
|
77
|
-
this.statusInterval = setInterval(() => this.emitStatusMetrics(), this.statusIntervalMs);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Unregister endpoint
|
|
81
|
-
* @param topic - The API topic
|
|
82
|
-
* @param attribute - The attribute for the topic.
|
|
83
|
-
* @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
84
|
-
*/
|
|
85
|
-
async unregister(topic, asset, objectType, objectId, attribute, method) {
|
|
86
|
-
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
87
|
-
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
88
|
-
const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
|
|
89
|
-
// Remove route from router
|
|
90
|
-
if (this.app.router?.stack) {
|
|
91
|
-
this.app.router.stack = this.app.router.stack.filter((layer) => {
|
|
92
|
-
return !(layer.route &&
|
|
93
|
-
layer.route.path === fullPath &&
|
|
94
|
-
layer.route.methods[methodKey]);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
// Remove from Swagger spec if path exists
|
|
98
|
-
if (this.app.swaggerSpec?.paths?.[apiPath]) {
|
|
99
|
-
delete this.app.swaggerSpec.paths[apiPath][methodKey];
|
|
100
|
-
// If no methods remain for the path, delete the whole path
|
|
101
|
-
if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
|
|
102
|
-
delete this.app.swaggerSpec.paths[apiPath];
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// Unregister from internal endpoint tracking
|
|
106
|
-
this.unregisterApiEndpoint(topic, asset, objectType, objectId, attribute);
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Register a GET endpoint with optional JWT path filter.
|
|
110
|
-
* @param topic - The API topic
|
|
111
|
-
* @param attribute - The attribute for the topic.
|
|
112
|
-
* @param options.description - Optional description.
|
|
113
|
-
* @param options.tags - Optional tags.
|
|
114
|
-
*/
|
|
115
|
-
async get(topic, asset, objectType, objectId, attribute, options) {
|
|
116
|
-
// Wait until the API server is started
|
|
117
|
-
while (this.app.server.listening === false) {
|
|
118
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
119
|
-
}
|
|
120
|
-
const time = UnsPacket.formatToISO8601(new Date());
|
|
121
|
-
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
122
|
-
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
123
|
-
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
124
|
-
try {
|
|
125
|
-
// Get ip and port from environment variables or defaults
|
|
126
|
-
const addressInfo = this.app.server.address();
|
|
127
|
-
let ip;
|
|
128
|
-
let port;
|
|
129
|
-
if (addressInfo && typeof addressInfo === "object") {
|
|
130
|
-
ip = App.getExternalIPv4();
|
|
131
|
-
port = addressInfo.port;
|
|
132
|
-
}
|
|
133
|
-
else if (typeof addressInfo === "string") {
|
|
134
|
-
ip = App.getExternalIPv4();
|
|
135
|
-
port = "";
|
|
136
|
-
}
|
|
137
|
-
this.registerApiEndpoint({
|
|
138
|
-
timestamp: time,
|
|
139
|
-
topic: topic,
|
|
140
|
-
attribute: attribute,
|
|
141
|
-
apiHost: `http://${ip}:${port}`,
|
|
142
|
-
apiEndpoint: apiPath,
|
|
143
|
-
apiMethod: "GET",
|
|
144
|
-
apiQueryParams: options.queryParams,
|
|
145
|
-
apiDescription: options?.apiDescription,
|
|
146
|
-
attributeType: UnsAttributeType.Api,
|
|
147
|
-
apiSwaggerEndpoint: swaggerPath,
|
|
148
|
-
asset,
|
|
149
|
-
objectType,
|
|
150
|
-
objectId
|
|
151
|
-
});
|
|
152
|
-
const handler = (req, res) => {
|
|
153
|
-
// Query param validation
|
|
154
|
-
if (options?.queryParams) {
|
|
155
|
-
const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
|
|
156
|
-
if (missingParams.length > 0) {
|
|
157
|
-
return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
|
|
158
|
-
}
|
|
159
|
-
// Optional: cast types (basic)
|
|
160
|
-
for (const param of options.queryParams) {
|
|
161
|
-
const value = req.query[param.name];
|
|
162
|
-
if (value !== undefined) {
|
|
163
|
-
switch (param.type) {
|
|
164
|
-
case "number":
|
|
165
|
-
if (isNaN(Number(value))) {
|
|
166
|
-
return res.status(400).json({ error: `Query param ${param.name} must be a number` });
|
|
167
|
-
}
|
|
168
|
-
break;
|
|
169
|
-
case "boolean":
|
|
170
|
-
if (!["true", "false", "1", "0"].includes(String(value))) {
|
|
171
|
-
return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
|
|
172
|
-
}
|
|
173
|
-
break;
|
|
174
|
-
// string: no check
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
this.event.emit("apiGetEvent", { req, res });
|
|
180
|
-
};
|
|
181
|
-
// JWT or JWKS or open
|
|
182
|
-
if (this.options?.jwks?.wellKnownJwksUrl) {
|
|
183
|
-
this.app.router.get(fullPath, async (req, res) => {
|
|
184
|
-
try {
|
|
185
|
-
const token = this.extractBearerToken(req, res);
|
|
186
|
-
if (!token)
|
|
187
|
-
return; // response already sent
|
|
188
|
-
const publicKey = await this.getPublicKeyFromJwks(token);
|
|
189
|
-
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
190
|
-
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
191
|
-
const accessRules = Array.isArray(decoded?.accessRules)
|
|
192
|
-
? decoded.accessRules
|
|
193
|
-
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
194
|
-
? [decoded.pathFilter]
|
|
195
|
-
: undefined);
|
|
196
|
-
const allowed = Array.isArray(accessRules)
|
|
197
|
-
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
198
|
-
: false;
|
|
199
|
-
if (!allowed) {
|
|
200
|
-
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
201
|
-
}
|
|
202
|
-
handler(req, res);
|
|
203
|
-
}
|
|
204
|
-
catch (err) {
|
|
205
|
-
return res.status(401).json({ error: "Invalid token" });
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
else if (this.options?.jwtSecret) {
|
|
210
|
-
this.app.router.get(fullPath, (req, res) => {
|
|
211
|
-
const authHeader = req.headers["authorization"];
|
|
212
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
213
|
-
return res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
214
|
-
}
|
|
215
|
-
const token = authHeader.slice(7);
|
|
216
|
-
try {
|
|
217
|
-
const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
218
|
-
const accessRules = Array.isArray(decoded?.accessRules)
|
|
219
|
-
? decoded.accessRules
|
|
220
|
-
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
221
|
-
? [decoded.pathFilter]
|
|
222
|
-
: undefined);
|
|
223
|
-
const allowed = Array.isArray(accessRules)
|
|
224
|
-
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
225
|
-
: false;
|
|
226
|
-
if (!allowed) {
|
|
227
|
-
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
228
|
-
}
|
|
229
|
-
handler(req, res);
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
return res.status(401).json({ error: "Invalid token" });
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
this.app.router.get(fullPath, handler);
|
|
238
|
-
}
|
|
239
|
-
if (this.app.swaggerSpec) {
|
|
240
|
-
const queryParams = options?.queryParams || [];
|
|
241
|
-
const canonicalParams = queryParams.reduce((acc, param) => {
|
|
242
|
-
if (typeof param.chatCanonical === "string" && param.chatCanonical.trim().length) {
|
|
243
|
-
acc[param.chatCanonical.trim()] = param.name;
|
|
244
|
-
}
|
|
245
|
-
return acc;
|
|
246
|
-
}, {});
|
|
247
|
-
const chatDefaults = {};
|
|
248
|
-
for (const param of queryParams) {
|
|
249
|
-
if (param.defaultValue !== undefined) {
|
|
250
|
-
chatDefaults[param.name] = param.defaultValue;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
const optionDefaults = options?.chatDefaults ?? {};
|
|
254
|
-
for (const [key, value] of Object.entries(optionDefaults)) {
|
|
255
|
-
if (value !== undefined) {
|
|
256
|
-
chatDefaults[key] = value;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const unsChatMeta = Object.keys(canonicalParams).length || Object.keys(chatDefaults).length
|
|
260
|
-
? {
|
|
261
|
-
canonicalParams,
|
|
262
|
-
defaults: chatDefaults,
|
|
263
|
-
}
|
|
264
|
-
: null;
|
|
265
|
-
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
266
|
-
this.app.swaggerSpec.paths[apiPath] = {
|
|
267
|
-
get: {
|
|
268
|
-
summary: options?.apiDescription || "No description",
|
|
269
|
-
tags: options?.tags || [],
|
|
270
|
-
parameters: queryParams.map((p) => ({
|
|
271
|
-
name: p.name,
|
|
272
|
-
in: "query",
|
|
273
|
-
required: !!p.required,
|
|
274
|
-
schema: {
|
|
275
|
-
type: p.type,
|
|
276
|
-
...(p.defaultValue !== undefined ? { default: p.defaultValue } : {}),
|
|
277
|
-
},
|
|
278
|
-
description: p.description,
|
|
279
|
-
...(p.chatCanonical ? { "x-uns-chat-canonical": p.chatCanonical } : {}),
|
|
280
|
-
})),
|
|
281
|
-
...(unsChatMeta ? { "x-uns-chat": unsChatMeta } : {}),
|
|
282
|
-
responses: {
|
|
283
|
-
"200": { description: "OK" },
|
|
284
|
-
"400": { description: "Bad Request" },
|
|
285
|
-
"401": { description: "Unauthorized" },
|
|
286
|
-
"403": { description: "Forbidden" },
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
|
|
298
|
-
* Does not create individual API attribute nodes; the controller treats this as a fallback.
|
|
299
|
-
*/
|
|
300
|
-
async registerCatchAll(topicPrefix, options) {
|
|
301
|
-
while (this.app.server.listening === false) {
|
|
302
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
303
|
-
}
|
|
304
|
-
const finalOptions = options ?? {};
|
|
305
|
-
const topicNormalized = topicPrefix.endsWith("/") ? topicPrefix : `${topicPrefix}`;
|
|
306
|
-
const addressInfo = this.app.server.address();
|
|
307
|
-
let ip;
|
|
308
|
-
let port;
|
|
309
|
-
if (addressInfo && typeof addressInfo === "object") {
|
|
310
|
-
ip = App.getExternalIPv4();
|
|
311
|
-
port = addressInfo.port;
|
|
312
|
-
}
|
|
313
|
-
else if (typeof addressInfo === "string") {
|
|
314
|
-
ip = App.getExternalIPv4();
|
|
315
|
-
port = "";
|
|
316
|
-
}
|
|
317
|
-
const apiBase = typeof finalOptions?.apiBase === "string" && finalOptions.apiBase.length
|
|
318
|
-
? finalOptions.apiBase
|
|
319
|
-
: `http://${ip}:${port}`;
|
|
320
|
-
const apiBasePath = typeof finalOptions?.apiBasePath === "string" && finalOptions.apiBasePath.length
|
|
321
|
-
? finalOptions.apiBasePath
|
|
322
|
-
: "/api";
|
|
323
|
-
const swaggerPath = typeof finalOptions?.swaggerPath === "string" && finalOptions.swaggerPath.length
|
|
324
|
-
? finalOptions.swaggerPath
|
|
325
|
-
: `/${this.processName}/${this.instanceName}/catchall-swagger.json`;
|
|
326
|
-
const normalizedSwaggerPath = swaggerPath.startsWith("/") ? swaggerPath : `/${swaggerPath}`;
|
|
327
|
-
const swaggerDoc = finalOptions.swaggerDoc ||
|
|
328
|
-
{
|
|
329
|
-
openapi: "3.0.0",
|
|
330
|
-
info: {
|
|
331
|
-
title: "Catch-all API",
|
|
332
|
-
version: "1.0.0",
|
|
333
|
-
},
|
|
334
|
-
paths: {
|
|
335
|
-
"/api/{topicPath}": {
|
|
336
|
-
get: {
|
|
337
|
-
summary: finalOptions.apiDescription || "Catch-all handler",
|
|
338
|
-
tags: finalOptions.tags || [],
|
|
339
|
-
parameters: [
|
|
340
|
-
{
|
|
341
|
-
name: "topicPath",
|
|
342
|
-
in: "path",
|
|
343
|
-
required: true,
|
|
344
|
-
schema: { type: "string" },
|
|
345
|
-
description: "Resolved UNS topic path",
|
|
346
|
-
},
|
|
347
|
-
...(finalOptions.queryParams || []).map((p) => ({
|
|
348
|
-
name: p.name,
|
|
349
|
-
in: "query",
|
|
350
|
-
required: !!p.required,
|
|
351
|
-
schema: { type: p.type },
|
|
352
|
-
description: p.description,
|
|
353
|
-
})),
|
|
354
|
-
],
|
|
355
|
-
responses: {
|
|
356
|
-
"200": { description: "OK" },
|
|
357
|
-
"400": { description: "Bad Request" },
|
|
358
|
-
"401": { description: "Unauthorized" },
|
|
359
|
-
"403": { description: "Forbidden" },
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
},
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
this.app.registerSwaggerDoc(normalizedSwaggerPath, swaggerDoc);
|
|
366
|
-
logger.info(`${this.instanceNameWithSuffix} - Catch-all Swagger available at ${normalizedSwaggerPath} (target ${apiBase.replace(/\/+$/, "")}${normalizedSwaggerPath})`);
|
|
367
|
-
if (!this.catchAllRouteRegistered) {
|
|
368
|
-
this.app.router.use((req, res) => {
|
|
369
|
-
const topicPath = (req.path ?? "").replace(/^\/+/, "");
|
|
370
|
-
req.params = { ...(req.params || {}), topicPath };
|
|
371
|
-
this.event.emit("apiGetEvent", { req, res });
|
|
372
|
-
});
|
|
373
|
-
this.catchAllRouteRegistered = true;
|
|
374
|
-
}
|
|
375
|
-
this.registerApiCatchAll({
|
|
376
|
-
topic: topicNormalized,
|
|
377
|
-
apiBase,
|
|
378
|
-
apiBasePath,
|
|
379
|
-
swaggerPath,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
post(..._args) {
|
|
383
|
-
// Implement POST logic or route binding here
|
|
384
|
-
return "POST called";
|
|
385
|
-
}
|
|
386
|
-
emitStatusMetrics() {
|
|
387
|
-
const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
|
|
388
|
-
// Process-level status
|
|
389
|
-
this.event.emit("mqttProxyStatus", {
|
|
390
|
-
event: "uptime",
|
|
391
|
-
value: uptimeMinutes,
|
|
392
|
-
uom: PhysicalMeasurements.Minute,
|
|
393
|
-
statusTopic: this.processStatusTopic + "uptime",
|
|
394
|
-
});
|
|
395
|
-
this.event.emit("mqttProxyStatus", {
|
|
396
|
-
event: "alive",
|
|
397
|
-
value: 1,
|
|
398
|
-
uom: DataSizeMeasurements.Bit,
|
|
399
|
-
statusTopic: this.processStatusTopic + "alive",
|
|
400
|
-
});
|
|
401
|
-
// Instance-level status
|
|
402
|
-
this.event.emit("mqttProxyStatus", {
|
|
403
|
-
event: "uptime",
|
|
404
|
-
value: uptimeMinutes,
|
|
405
|
-
uom: PhysicalMeasurements.Minute,
|
|
406
|
-
statusTopic: this.instanceStatusTopic + "uptime",
|
|
407
|
-
});
|
|
408
|
-
this.event.emit("mqttProxyStatus", {
|
|
409
|
-
event: "alive",
|
|
410
|
-
value: 1,
|
|
411
|
-
uom: DataSizeMeasurements.Bit,
|
|
412
|
-
statusTopic: this.instanceStatusTopic + "alive",
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
registerHealthEndpoint() {
|
|
416
|
-
const routePath = "/status";
|
|
417
|
-
this.app.router.get(routePath, (_req, res) => {
|
|
418
|
-
res.json({
|
|
419
|
-
alive: true,
|
|
420
|
-
processName: this.processName,
|
|
421
|
-
instanceName: this.instanceName,
|
|
422
|
-
package: packageJson.name,
|
|
423
|
-
version: packageJson.version,
|
|
424
|
-
startedAt: new Date(this.startedAt).toISOString(),
|
|
425
|
-
uptimeMs: Date.now() - this.startedAt,
|
|
426
|
-
timestamp: new Date().toISOString(),
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
if (this.app.swaggerSpec) {
|
|
430
|
-
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
431
|
-
const swaggerPath = `${this.apiBasePrefix}${routePath}`.replace(/\/{2,}/g, "/");
|
|
432
|
-
this.app.swaggerSpec.paths[swaggerPath] = {
|
|
433
|
-
get: {
|
|
434
|
-
summary: "Health status",
|
|
435
|
-
responses: {
|
|
436
|
-
"200": { description: "OK" },
|
|
437
|
-
},
|
|
438
|
-
},
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
extractBearerToken(req, res) {
|
|
443
|
-
const authHeader = req.headers["authorization"];
|
|
444
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
445
|
-
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
446
|
-
return undefined;
|
|
447
|
-
}
|
|
448
|
-
return authHeader.slice(7);
|
|
449
|
-
}
|
|
450
|
-
async getPublicKeyFromJwks(token) {
|
|
451
|
-
// Decode header to get kid
|
|
452
|
-
const decoded = jwt.decode(token, { complete: true });
|
|
453
|
-
const kid = decoded?.header?.kid;
|
|
454
|
-
const keys = await this.fetchJwksKeys();
|
|
455
|
-
let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
|
|
456
|
-
// If no kid match and activeKidUrl configured, try that
|
|
457
|
-
if (!jwk && this.options?.jwks?.activeKidUrl) {
|
|
458
|
-
try {
|
|
459
|
-
const resp = await fetch(this.options.jwks.activeKidUrl);
|
|
460
|
-
if (resp.ok) {
|
|
461
|
-
const activeKid = await resp.text();
|
|
462
|
-
jwk = keys.find((k) => k.kid === activeKid.trim());
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
catch (_) {
|
|
466
|
-
// ignore and fall through
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
// If still not found but only one key, use it
|
|
470
|
-
if (!jwk && keys.length === 1) {
|
|
471
|
-
jwk = keys[0];
|
|
472
|
-
}
|
|
473
|
-
if (!jwk) {
|
|
474
|
-
throw new Error("Signing key not found in JWKS");
|
|
475
|
-
}
|
|
476
|
-
// Prefer x5c certificate if provided
|
|
477
|
-
if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
|
|
478
|
-
return this.certFromX5c(jwk.x5c[0]);
|
|
479
|
-
}
|
|
480
|
-
// Build PEM from JWK (RSA)
|
|
481
|
-
if (jwk.kty === "RSA" && jwk.n && jwk.e) {
|
|
482
|
-
const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
|
|
483
|
-
return keyObj.export({ type: "spki", format: "pem" }).toString();
|
|
484
|
-
}
|
|
485
|
-
throw new Error("Unsupported JWK format");
|
|
486
|
-
}
|
|
487
|
-
async fetchJwksKeys() {
|
|
488
|
-
const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
|
|
489
|
-
const now = Date.now();
|
|
490
|
-
if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
|
|
491
|
-
return this.jwksCache.keys;
|
|
492
|
-
}
|
|
493
|
-
const url = this.options.jwks.wellKnownJwksUrl;
|
|
494
|
-
const resp = await fetch(url);
|
|
495
|
-
if (!resp.ok) {
|
|
496
|
-
throw new Error(`Failed to fetch JWKS (${resp.status})`);
|
|
497
|
-
}
|
|
498
|
-
const body = await resp.json();
|
|
499
|
-
const keys = Array.isArray(body?.keys) ? body.keys : [];
|
|
500
|
-
this.jwksCache = { keys, fetchedAt: now };
|
|
501
|
-
return keys;
|
|
502
|
-
}
|
|
503
|
-
certFromX5c(x5cFirst) {
|
|
504
|
-
const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
|
|
505
|
-
return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
|
|
506
|
-
}
|
|
507
|
-
async stop() {
|
|
508
|
-
if (this.statusInterval) {
|
|
509
|
-
clearInterval(this.statusInterval);
|
|
510
|
-
this.statusInterval = null;
|
|
511
|
-
}
|
|
512
|
-
await super.stop();
|
|
513
|
-
}
|
|
514
|
-
}
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createPublicKey } from "crypto";
|
|
5
|
+
import { basePath } from "@uns-kit/core/base-path.js";
|
|
6
|
+
import { UnsAttributeType } from "@uns-kit/core/graphql/schema.js";
|
|
7
|
+
import logger from "@uns-kit/core/logger.js";
|
|
8
|
+
import { MqttTopicBuilder } from "@uns-kit/core/uns-mqtt/mqtt-topic-builder.js";
|
|
9
|
+
import { UnsPacket } from "@uns-kit/core/uns/uns-packet.js";
|
|
10
|
+
import UnsProxy from "@uns-kit/core/uns/uns-proxy.js";
|
|
11
|
+
import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher.js";
|
|
12
|
+
import { DataSizeMeasurements, PhysicalMeasurements } from "@uns-kit/core/uns/uns-measurements.js";
|
|
13
|
+
import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
|
|
14
|
+
import App from "./app.js";
|
|
15
|
+
const packageJsonPath = path.join(basePath, "package.json");
|
|
16
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
17
|
+
const normalizeBasePrefix = (value) => {
|
|
18
|
+
if (!value)
|
|
19
|
+
return "";
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return "";
|
|
23
|
+
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
24
|
+
return withLeading.replace(/\/+$/, "");
|
|
25
|
+
};
|
|
26
|
+
const buildSwaggerPath = (base, processName, instanceName) => {
|
|
27
|
+
const processSegment = `/${processName}`;
|
|
28
|
+
let baseWithProcess = base || "/";
|
|
29
|
+
if (!baseWithProcess.endsWith(processSegment)) {
|
|
30
|
+
baseWithProcess = `${baseWithProcess}${processSegment}`;
|
|
31
|
+
}
|
|
32
|
+
return `${baseWithProcess}/${instanceName}/swagger.json`.replace(/\/{2,}/g, "/");
|
|
33
|
+
};
|
|
34
|
+
export default class UnsApiProxy extends UnsProxy {
|
|
35
|
+
instanceName;
|
|
36
|
+
topicBuilder;
|
|
37
|
+
processName;
|
|
38
|
+
processStatusTopic;
|
|
39
|
+
app;
|
|
40
|
+
options;
|
|
41
|
+
apiBasePrefix;
|
|
42
|
+
swaggerBasePrefix;
|
|
43
|
+
jwksCache;
|
|
44
|
+
catchAllRouteRegistered = false;
|
|
45
|
+
startedAt;
|
|
46
|
+
statusInterval = null;
|
|
47
|
+
statusIntervalMs = 10_000;
|
|
48
|
+
constructor(processName, instanceName, options) {
|
|
49
|
+
super();
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.apiBasePrefix =
|
|
52
|
+
normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
|
|
53
|
+
const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
|
|
54
|
+
this.swaggerBasePrefix = rawSwaggerBase.endsWith("/api")
|
|
55
|
+
? rawSwaggerBase.replace(/\/api\/?$/, "") || "/"
|
|
56
|
+
: rawSwaggerBase;
|
|
57
|
+
this.app = new App(0, processName, instanceName, undefined, {
|
|
58
|
+
apiBasePrefix: this.apiBasePrefix,
|
|
59
|
+
swaggerBasePrefix: this.swaggerBasePrefix,
|
|
60
|
+
disableDefaultApiMount: options.disableDefaultApiMount ?? false,
|
|
61
|
+
});
|
|
62
|
+
this.app.start();
|
|
63
|
+
this.startedAt = Date.now();
|
|
64
|
+
this.instanceName = instanceName;
|
|
65
|
+
this.processName = processName;
|
|
66
|
+
// Create the topic builder using packageJson values and the processName.
|
|
67
|
+
this.topicBuilder = new MqttTopicBuilder(`uns-infra/${MqttTopicBuilder.sanitizeTopicPart(packageJson.name)}/${MqttTopicBuilder.sanitizeTopicPart(packageJson.version)}/${MqttTopicBuilder.sanitizeTopicPart(processName)}/`);
|
|
68
|
+
// Generate the processStatusTopic using the builder.
|
|
69
|
+
this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
|
|
70
|
+
// Derive the instanceStatusTopic by appending the instance name.
|
|
71
|
+
this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
|
|
72
|
+
// Concatenate processName with instanceName for the worker identification.
|
|
73
|
+
this.instanceNameWithSuffix = `${processName}-${instanceName}`;
|
|
74
|
+
this.registerHealthEndpoint();
|
|
75
|
+
// Emit once after listeners are attached in the plugin, then on the regular cadence.
|
|
76
|
+
setTimeout(() => this.emitStatusMetrics(), 0);
|
|
77
|
+
this.statusInterval = setInterval(() => this.emitStatusMetrics(), this.statusIntervalMs);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Unregister endpoint
|
|
81
|
+
* @param topic - The API topic
|
|
82
|
+
* @param attribute - The attribute for the topic.
|
|
83
|
+
* @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
84
|
+
*/
|
|
85
|
+
async unregister(topic, asset, objectType, objectId, attribute, method) {
|
|
86
|
+
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
87
|
+
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
88
|
+
const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
|
|
89
|
+
// Remove route from router
|
|
90
|
+
if (this.app.router?.stack) {
|
|
91
|
+
this.app.router.stack = this.app.router.stack.filter((layer) => {
|
|
92
|
+
return !(layer.route &&
|
|
93
|
+
layer.route.path === fullPath &&
|
|
94
|
+
layer.route.methods[methodKey]);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Remove from Swagger spec if path exists
|
|
98
|
+
if (this.app.swaggerSpec?.paths?.[apiPath]) {
|
|
99
|
+
delete this.app.swaggerSpec.paths[apiPath][methodKey];
|
|
100
|
+
// If no methods remain for the path, delete the whole path
|
|
101
|
+
if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
|
|
102
|
+
delete this.app.swaggerSpec.paths[apiPath];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Unregister from internal endpoint tracking
|
|
106
|
+
this.unregisterApiEndpoint(topic, asset, objectType, objectId, attribute);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Register a GET endpoint with optional JWT path filter.
|
|
110
|
+
* @param topic - The API topic
|
|
111
|
+
* @param attribute - The attribute for the topic.
|
|
112
|
+
* @param options.description - Optional description.
|
|
113
|
+
* @param options.tags - Optional tags.
|
|
114
|
+
*/
|
|
115
|
+
async get(topic, asset, objectType, objectId, attribute, options) {
|
|
116
|
+
// Wait until the API server is started
|
|
117
|
+
while (this.app.server.listening === false) {
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
119
|
+
}
|
|
120
|
+
const time = UnsPacket.formatToISO8601(new Date());
|
|
121
|
+
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
122
|
+
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
123
|
+
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
124
|
+
try {
|
|
125
|
+
// Get ip and port from environment variables or defaults
|
|
126
|
+
const addressInfo = this.app.server.address();
|
|
127
|
+
let ip;
|
|
128
|
+
let port;
|
|
129
|
+
if (addressInfo && typeof addressInfo === "object") {
|
|
130
|
+
ip = App.getExternalIPv4();
|
|
131
|
+
port = addressInfo.port;
|
|
132
|
+
}
|
|
133
|
+
else if (typeof addressInfo === "string") {
|
|
134
|
+
ip = App.getExternalIPv4();
|
|
135
|
+
port = "";
|
|
136
|
+
}
|
|
137
|
+
this.registerApiEndpoint({
|
|
138
|
+
timestamp: time,
|
|
139
|
+
topic: topic,
|
|
140
|
+
attribute: attribute,
|
|
141
|
+
apiHost: `http://${ip}:${port}`,
|
|
142
|
+
apiEndpoint: apiPath,
|
|
143
|
+
apiMethod: "GET",
|
|
144
|
+
apiQueryParams: options.queryParams,
|
|
145
|
+
apiDescription: options?.apiDescription,
|
|
146
|
+
attributeType: UnsAttributeType.Api,
|
|
147
|
+
apiSwaggerEndpoint: swaggerPath,
|
|
148
|
+
asset,
|
|
149
|
+
objectType,
|
|
150
|
+
objectId
|
|
151
|
+
});
|
|
152
|
+
const handler = (req, res) => {
|
|
153
|
+
// Query param validation
|
|
154
|
+
if (options?.queryParams) {
|
|
155
|
+
const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
|
|
156
|
+
if (missingParams.length > 0) {
|
|
157
|
+
return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
|
|
158
|
+
}
|
|
159
|
+
// Optional: cast types (basic)
|
|
160
|
+
for (const param of options.queryParams) {
|
|
161
|
+
const value = req.query[param.name];
|
|
162
|
+
if (value !== undefined) {
|
|
163
|
+
switch (param.type) {
|
|
164
|
+
case "number":
|
|
165
|
+
if (isNaN(Number(value))) {
|
|
166
|
+
return res.status(400).json({ error: `Query param ${param.name} must be a number` });
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case "boolean":
|
|
170
|
+
if (!["true", "false", "1", "0"].includes(String(value))) {
|
|
171
|
+
return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
// string: no check
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.event.emit("apiGetEvent", { req, res });
|
|
180
|
+
};
|
|
181
|
+
// JWT or JWKS or open
|
|
182
|
+
if (this.options?.jwks?.wellKnownJwksUrl) {
|
|
183
|
+
this.app.router.get(fullPath, async (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const token = this.extractBearerToken(req, res);
|
|
186
|
+
if (!token)
|
|
187
|
+
return; // response already sent
|
|
188
|
+
const publicKey = await this.getPublicKeyFromJwks(token);
|
|
189
|
+
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
190
|
+
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
191
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
192
|
+
? decoded.accessRules
|
|
193
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
194
|
+
? [decoded.pathFilter]
|
|
195
|
+
: undefined);
|
|
196
|
+
const allowed = Array.isArray(accessRules)
|
|
197
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
198
|
+
: false;
|
|
199
|
+
if (!allowed) {
|
|
200
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
201
|
+
}
|
|
202
|
+
handler(req, res);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
else if (this.options?.jwtSecret) {
|
|
210
|
+
this.app.router.get(fullPath, (req, res) => {
|
|
211
|
+
const authHeader = req.headers["authorization"];
|
|
212
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
213
|
+
return res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
214
|
+
}
|
|
215
|
+
const token = authHeader.slice(7);
|
|
216
|
+
try {
|
|
217
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
218
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
219
|
+
? decoded.accessRules
|
|
220
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
221
|
+
? [decoded.pathFilter]
|
|
222
|
+
: undefined);
|
|
223
|
+
const allowed = Array.isArray(accessRules)
|
|
224
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
225
|
+
: false;
|
|
226
|
+
if (!allowed) {
|
|
227
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
228
|
+
}
|
|
229
|
+
handler(req, res);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
this.app.router.get(fullPath, handler);
|
|
238
|
+
}
|
|
239
|
+
if (this.app.swaggerSpec) {
|
|
240
|
+
const queryParams = options?.queryParams || [];
|
|
241
|
+
const canonicalParams = queryParams.reduce((acc, param) => {
|
|
242
|
+
if (typeof param.chatCanonical === "string" && param.chatCanonical.trim().length) {
|
|
243
|
+
acc[param.chatCanonical.trim()] = param.name;
|
|
244
|
+
}
|
|
245
|
+
return acc;
|
|
246
|
+
}, {});
|
|
247
|
+
const chatDefaults = {};
|
|
248
|
+
for (const param of queryParams) {
|
|
249
|
+
if (param.defaultValue !== undefined) {
|
|
250
|
+
chatDefaults[param.name] = param.defaultValue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const optionDefaults = options?.chatDefaults ?? {};
|
|
254
|
+
for (const [key, value] of Object.entries(optionDefaults)) {
|
|
255
|
+
if (value !== undefined) {
|
|
256
|
+
chatDefaults[key] = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const unsChatMeta = Object.keys(canonicalParams).length || Object.keys(chatDefaults).length
|
|
260
|
+
? {
|
|
261
|
+
canonicalParams,
|
|
262
|
+
defaults: chatDefaults,
|
|
263
|
+
}
|
|
264
|
+
: null;
|
|
265
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
266
|
+
this.app.swaggerSpec.paths[apiPath] = {
|
|
267
|
+
get: {
|
|
268
|
+
summary: options?.apiDescription || "No description",
|
|
269
|
+
tags: options?.tags || [],
|
|
270
|
+
parameters: queryParams.map((p) => ({
|
|
271
|
+
name: p.name,
|
|
272
|
+
in: "query",
|
|
273
|
+
required: !!p.required,
|
|
274
|
+
schema: {
|
|
275
|
+
type: p.type,
|
|
276
|
+
...(p.defaultValue !== undefined ? { default: p.defaultValue } : {}),
|
|
277
|
+
},
|
|
278
|
+
description: p.description,
|
|
279
|
+
...(p.chatCanonical ? { "x-uns-chat-canonical": p.chatCanonical } : {}),
|
|
280
|
+
})),
|
|
281
|
+
...(unsChatMeta ? { "x-uns-chat": unsChatMeta } : {}),
|
|
282
|
+
responses: {
|
|
283
|
+
"200": { description: "OK" },
|
|
284
|
+
"400": { description: "Bad Request" },
|
|
285
|
+
"401": { description: "Unauthorized" },
|
|
286
|
+
"403": { description: "Forbidden" },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
|
|
298
|
+
* Does not create individual API attribute nodes; the controller treats this as a fallback.
|
|
299
|
+
*/
|
|
300
|
+
async registerCatchAll(topicPrefix, options) {
|
|
301
|
+
while (this.app.server.listening === false) {
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
303
|
+
}
|
|
304
|
+
const finalOptions = options ?? {};
|
|
305
|
+
const topicNormalized = topicPrefix.endsWith("/") ? topicPrefix : `${topicPrefix}`;
|
|
306
|
+
const addressInfo = this.app.server.address();
|
|
307
|
+
let ip;
|
|
308
|
+
let port;
|
|
309
|
+
if (addressInfo && typeof addressInfo === "object") {
|
|
310
|
+
ip = App.getExternalIPv4();
|
|
311
|
+
port = addressInfo.port;
|
|
312
|
+
}
|
|
313
|
+
else if (typeof addressInfo === "string") {
|
|
314
|
+
ip = App.getExternalIPv4();
|
|
315
|
+
port = "";
|
|
316
|
+
}
|
|
317
|
+
const apiBase = typeof finalOptions?.apiBase === "string" && finalOptions.apiBase.length
|
|
318
|
+
? finalOptions.apiBase
|
|
319
|
+
: `http://${ip}:${port}`;
|
|
320
|
+
const apiBasePath = typeof finalOptions?.apiBasePath === "string" && finalOptions.apiBasePath.length
|
|
321
|
+
? finalOptions.apiBasePath
|
|
322
|
+
: "/api";
|
|
323
|
+
const swaggerPath = typeof finalOptions?.swaggerPath === "string" && finalOptions.swaggerPath.length
|
|
324
|
+
? finalOptions.swaggerPath
|
|
325
|
+
: `/${this.processName}/${this.instanceName}/catchall-swagger.json`;
|
|
326
|
+
const normalizedSwaggerPath = swaggerPath.startsWith("/") ? swaggerPath : `/${swaggerPath}`;
|
|
327
|
+
const swaggerDoc = finalOptions.swaggerDoc ||
|
|
328
|
+
{
|
|
329
|
+
openapi: "3.0.0",
|
|
330
|
+
info: {
|
|
331
|
+
title: "Catch-all API",
|
|
332
|
+
version: "1.0.0",
|
|
333
|
+
},
|
|
334
|
+
paths: {
|
|
335
|
+
"/api/{topicPath}": {
|
|
336
|
+
get: {
|
|
337
|
+
summary: finalOptions.apiDescription || "Catch-all handler",
|
|
338
|
+
tags: finalOptions.tags || [],
|
|
339
|
+
parameters: [
|
|
340
|
+
{
|
|
341
|
+
name: "topicPath",
|
|
342
|
+
in: "path",
|
|
343
|
+
required: true,
|
|
344
|
+
schema: { type: "string" },
|
|
345
|
+
description: "Resolved UNS topic path",
|
|
346
|
+
},
|
|
347
|
+
...(finalOptions.queryParams || []).map((p) => ({
|
|
348
|
+
name: p.name,
|
|
349
|
+
in: "query",
|
|
350
|
+
required: !!p.required,
|
|
351
|
+
schema: { type: p.type },
|
|
352
|
+
description: p.description,
|
|
353
|
+
})),
|
|
354
|
+
],
|
|
355
|
+
responses: {
|
|
356
|
+
"200": { description: "OK" },
|
|
357
|
+
"400": { description: "Bad Request" },
|
|
358
|
+
"401": { description: "Unauthorized" },
|
|
359
|
+
"403": { description: "Forbidden" },
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
this.app.registerSwaggerDoc(normalizedSwaggerPath, swaggerDoc);
|
|
366
|
+
logger.info(`${this.instanceNameWithSuffix} - Catch-all Swagger available at ${normalizedSwaggerPath} (target ${apiBase.replace(/\/+$/, "")}${normalizedSwaggerPath})`);
|
|
367
|
+
if (!this.catchAllRouteRegistered) {
|
|
368
|
+
this.app.router.use((req, res) => {
|
|
369
|
+
const topicPath = (req.path ?? "").replace(/^\/+/, "");
|
|
370
|
+
req.params = { ...(req.params || {}), topicPath };
|
|
371
|
+
this.event.emit("apiGetEvent", { req, res });
|
|
372
|
+
});
|
|
373
|
+
this.catchAllRouteRegistered = true;
|
|
374
|
+
}
|
|
375
|
+
this.registerApiCatchAll({
|
|
376
|
+
topic: topicNormalized,
|
|
377
|
+
apiBase,
|
|
378
|
+
apiBasePath,
|
|
379
|
+
swaggerPath,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
post(..._args) {
|
|
383
|
+
// Implement POST logic or route binding here
|
|
384
|
+
return "POST called";
|
|
385
|
+
}
|
|
386
|
+
emitStatusMetrics() {
|
|
387
|
+
const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
|
|
388
|
+
// Process-level status
|
|
389
|
+
this.event.emit("mqttProxyStatus", {
|
|
390
|
+
event: "uptime",
|
|
391
|
+
value: uptimeMinutes,
|
|
392
|
+
uom: PhysicalMeasurements.Minute,
|
|
393
|
+
statusTopic: this.processStatusTopic + "uptime",
|
|
394
|
+
});
|
|
395
|
+
this.event.emit("mqttProxyStatus", {
|
|
396
|
+
event: "alive",
|
|
397
|
+
value: 1,
|
|
398
|
+
uom: DataSizeMeasurements.Bit,
|
|
399
|
+
statusTopic: this.processStatusTopic + "alive",
|
|
400
|
+
});
|
|
401
|
+
// Instance-level status
|
|
402
|
+
this.event.emit("mqttProxyStatus", {
|
|
403
|
+
event: "uptime",
|
|
404
|
+
value: uptimeMinutes,
|
|
405
|
+
uom: PhysicalMeasurements.Minute,
|
|
406
|
+
statusTopic: this.instanceStatusTopic + "uptime",
|
|
407
|
+
});
|
|
408
|
+
this.event.emit("mqttProxyStatus", {
|
|
409
|
+
event: "alive",
|
|
410
|
+
value: 1,
|
|
411
|
+
uom: DataSizeMeasurements.Bit,
|
|
412
|
+
statusTopic: this.instanceStatusTopic + "alive",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
registerHealthEndpoint() {
|
|
416
|
+
const routePath = "/status";
|
|
417
|
+
this.app.router.get(routePath, (_req, res) => {
|
|
418
|
+
res.json({
|
|
419
|
+
alive: true,
|
|
420
|
+
processName: this.processName,
|
|
421
|
+
instanceName: this.instanceName,
|
|
422
|
+
package: packageJson.name,
|
|
423
|
+
version: packageJson.version,
|
|
424
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
425
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
426
|
+
timestamp: new Date().toISOString(),
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
if (this.app.swaggerSpec) {
|
|
430
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
431
|
+
const swaggerPath = `${this.apiBasePrefix}${routePath}`.replace(/\/{2,}/g, "/");
|
|
432
|
+
this.app.swaggerSpec.paths[swaggerPath] = {
|
|
433
|
+
get: {
|
|
434
|
+
summary: "Health status",
|
|
435
|
+
responses: {
|
|
436
|
+
"200": { description: "OK" },
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
extractBearerToken(req, res) {
|
|
443
|
+
const authHeader = req.headers["authorization"];
|
|
444
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
445
|
+
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
return authHeader.slice(7);
|
|
449
|
+
}
|
|
450
|
+
async getPublicKeyFromJwks(token) {
|
|
451
|
+
// Decode header to get kid
|
|
452
|
+
const decoded = jwt.decode(token, { complete: true });
|
|
453
|
+
const kid = decoded?.header?.kid;
|
|
454
|
+
const keys = await this.fetchJwksKeys();
|
|
455
|
+
let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
|
|
456
|
+
// If no kid match and activeKidUrl configured, try that
|
|
457
|
+
if (!jwk && this.options?.jwks?.activeKidUrl) {
|
|
458
|
+
try {
|
|
459
|
+
const resp = await fetch(this.options.jwks.activeKidUrl);
|
|
460
|
+
if (resp.ok) {
|
|
461
|
+
const activeKid = await resp.text();
|
|
462
|
+
jwk = keys.find((k) => k.kid === activeKid.trim());
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (_) {
|
|
466
|
+
// ignore and fall through
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// If still not found but only one key, use it
|
|
470
|
+
if (!jwk && keys.length === 1) {
|
|
471
|
+
jwk = keys[0];
|
|
472
|
+
}
|
|
473
|
+
if (!jwk) {
|
|
474
|
+
throw new Error("Signing key not found in JWKS");
|
|
475
|
+
}
|
|
476
|
+
// Prefer x5c certificate if provided
|
|
477
|
+
if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
|
|
478
|
+
return this.certFromX5c(jwk.x5c[0]);
|
|
479
|
+
}
|
|
480
|
+
// Build PEM from JWK (RSA)
|
|
481
|
+
if (jwk.kty === "RSA" && jwk.n && jwk.e) {
|
|
482
|
+
const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
|
|
483
|
+
return keyObj.export({ type: "spki", format: "pem" }).toString();
|
|
484
|
+
}
|
|
485
|
+
throw new Error("Unsupported JWK format");
|
|
486
|
+
}
|
|
487
|
+
async fetchJwksKeys() {
|
|
488
|
+
const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
|
|
491
|
+
return this.jwksCache.keys;
|
|
492
|
+
}
|
|
493
|
+
const url = this.options.jwks.wellKnownJwksUrl;
|
|
494
|
+
const resp = await fetch(url);
|
|
495
|
+
if (!resp.ok) {
|
|
496
|
+
throw new Error(`Failed to fetch JWKS (${resp.status})`);
|
|
497
|
+
}
|
|
498
|
+
const body = await resp.json();
|
|
499
|
+
const keys = Array.isArray(body?.keys) ? body.keys : [];
|
|
500
|
+
this.jwksCache = { keys, fetchedAt: now };
|
|
501
|
+
return keys;
|
|
502
|
+
}
|
|
503
|
+
certFromX5c(x5cFirst) {
|
|
504
|
+
const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
|
|
505
|
+
return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
|
|
506
|
+
}
|
|
507
|
+
async stop() {
|
|
508
|
+
if (this.statusInterval) {
|
|
509
|
+
clearInterval(this.statusInterval);
|
|
510
|
+
this.statusInterval = null;
|
|
511
|
+
}
|
|
512
|
+
await super.stop();
|
|
513
|
+
}
|
|
514
|
+
}
|