@uns-kit/api 2.0.22 → 2.0.24

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.
@@ -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
+ }