@uns-kit/api 3.0.24 → 3.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.
@@ -0,0 +1,846 @@
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
+ const normalizeStringArray = (value) => Array.isArray(value)
35
+ ? Array.from(new Set(value
36
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
37
+ .filter((entry) => entry.length > 0)))
38
+ : [];
39
+ const normalizePathValue = (value) => {
40
+ if (typeof value !== "string") {
41
+ return null;
42
+ }
43
+ const trimmed = value.trim();
44
+ if (!trimmed) {
45
+ return null;
46
+ }
47
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
48
+ };
49
+ export default class UnsApiProxy extends UnsProxy {
50
+ instanceName;
51
+ topicBuilder;
52
+ processName;
53
+ processStatusTopic;
54
+ app;
55
+ options;
56
+ apiBasePrefix;
57
+ swaggerBasePrefix;
58
+ jwksCache;
59
+ catchAllRouteRegistered = false;
60
+ startedAt;
61
+ statusInterval = null;
62
+ statusIntervalMs = 10_000;
63
+ dataCatalogOffers = new Map();
64
+ constructor(processName, instanceName, options) {
65
+ super();
66
+ this.options = options;
67
+ this.apiBasePrefix =
68
+ normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
69
+ const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
70
+ this.swaggerBasePrefix = rawSwaggerBase.endsWith("/api")
71
+ ? rawSwaggerBase.replace(/\/api\/?$/, "") || "/"
72
+ : rawSwaggerBase;
73
+ this.app = new App(0, processName, instanceName, undefined, {
74
+ apiBasePrefix: this.apiBasePrefix,
75
+ swaggerBasePrefix: this.swaggerBasePrefix,
76
+ disableDefaultApiMount: options.disableDefaultApiMount ?? false,
77
+ });
78
+ this.app.start();
79
+ this.startedAt = Date.now();
80
+ this.instanceName = instanceName;
81
+ this.processName = processName;
82
+ // Create the topic builder using packageJson values and the processName.
83
+ this.topicBuilder = new MqttTopicBuilder(`uns-infra/${MqttTopicBuilder.sanitizeTopicPart(packageJson.name)}/${MqttTopicBuilder.sanitizeTopicPart(packageJson.version)}/${MqttTopicBuilder.sanitizeTopicPart(processName)}/`);
84
+ // Generate the processStatusTopic using the builder.
85
+ this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
86
+ // Derive the instanceStatusTopic by appending the instance name.
87
+ this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
88
+ // Concatenate processName with instanceName for the worker identification.
89
+ this.instanceNameWithSuffix = `${processName}-${instanceName}`;
90
+ this.registerHealthEndpoint();
91
+ // Emit once after listeners are attached in the plugin, then on the regular cadence.
92
+ setTimeout(() => this.emitStatusMetrics(), 0);
93
+ this.statusInterval = setInterval(() => this.emitStatusMetrics(), this.statusIntervalMs);
94
+ }
95
+ getProcessName() {
96
+ return this.processName;
97
+ }
98
+ getPublishedApiHost() {
99
+ const addressInfo = this.app.server.address();
100
+ const port = addressInfo && typeof addressInfo === "object" ? addressInfo.port : undefined;
101
+ const configuredHost = this.options.publishedApiHost?.trim() || process.env.UNS_PUBLISHED_API_HOST?.trim();
102
+ if (configuredHost) {
103
+ const origin = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(configuredHost)
104
+ ? configuredHost
105
+ : `http://${configuredHost}`);
106
+ if (!origin.port && port !== undefined) {
107
+ origin.port = String(port);
108
+ }
109
+ origin.pathname = "";
110
+ origin.search = "";
111
+ origin.hash = "";
112
+ return origin.origin;
113
+ }
114
+ return port === undefined
115
+ ? `http://${App.getExternalIPv4()}`
116
+ : `http://${App.getExternalIPv4()}:${port}`;
117
+ }
118
+ /**
119
+ * Unregister endpoint
120
+ * @param topic - The API topic
121
+ * @param attribute - The attribute for the topic.
122
+ * @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
123
+ */
124
+ async unregister(topic, asset, objectType, objectId, attribute, method) {
125
+ const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
126
+ const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
127
+ const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
128
+ // Remove route from router
129
+ if (this.app.router?.stack) {
130
+ this.app.router.stack = this.app.router.stack.filter((layer) => {
131
+ return !(layer.route &&
132
+ layer.route.path === fullPath &&
133
+ layer.route.methods[methodKey]);
134
+ });
135
+ }
136
+ // Remove from Swagger spec if path exists
137
+ if (this.app.swaggerSpec?.paths?.[apiPath]) {
138
+ delete this.app.swaggerSpec.paths[apiPath][methodKey];
139
+ // If no methods remain for the path, delete the whole path
140
+ if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
141
+ delete this.app.swaggerSpec.paths[apiPath];
142
+ }
143
+ }
144
+ // Unregister from internal endpoint tracking
145
+ this.unregisterApiEndpoint(topic, asset, objectType, objectId, attribute);
146
+ }
147
+ /**
148
+ * Register a GET endpoint with optional JWT path filter.
149
+ * @param topic - The API topic
150
+ * @param attribute - The attribute for the topic.
151
+ * @param options.description - Optional description.
152
+ * @param options.tags - Optional tags.
153
+ */
154
+ async get(topic, asset, objectType, objectId, attribute, options) {
155
+ // Wait until the API server is started
156
+ while (this.app.server.listening === false) {
157
+ await new Promise((resolve) => setTimeout(resolve, 100));
158
+ }
159
+ const time = UnsPacket.formatToISO8601(new Date());
160
+ const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
161
+ const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
162
+ const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
163
+ try {
164
+ this.registerApiEndpoint({
165
+ timestamp: time,
166
+ topic: topic,
167
+ attribute: attribute,
168
+ routeOnly: options?.routeOnly === true,
169
+ registryTopic: options?.registryTopic ??
170
+ "api-endpoints",
171
+ apiHost: this.getPublishedApiHost(),
172
+ apiEndpoint: apiPath,
173
+ apiMethod: "GET",
174
+ apiQueryParams: options?.queryParams ?? [],
175
+ apiDescription: options?.apiDescription,
176
+ serviceApi: options?.serviceApi ?? null,
177
+ attributeType: UnsAttributeType.Api,
178
+ apiSwaggerEndpoint: swaggerPath,
179
+ asset,
180
+ objectType,
181
+ objectId
182
+ });
183
+ const handler = (req, res) => {
184
+ // Query param validation
185
+ if (options?.queryParams) {
186
+ const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
187
+ if (missingParams.length > 0) {
188
+ return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
189
+ }
190
+ // Optional: cast types (basic)
191
+ for (const param of options.queryParams) {
192
+ const value = req.query[param.name];
193
+ if (value !== undefined) {
194
+ switch (param.type) {
195
+ case "number":
196
+ if (isNaN(Number(value))) {
197
+ return res.status(400).json({ error: `Query param ${param.name} must be a number` });
198
+ }
199
+ break;
200
+ case "boolean":
201
+ if (!["true", "false", "1", "0"].includes(String(value))) {
202
+ return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
203
+ }
204
+ break;
205
+ // string: no check
206
+ }
207
+ }
208
+ }
209
+ }
210
+ this.event.emit("apiGetEvent", { req, res });
211
+ };
212
+ // JWT or JWKS or open
213
+ if (this.options?.jwks?.wellKnownJwksUrl) {
214
+ this.app.router.get(fullPath, async (req, res) => {
215
+ try {
216
+ const token = this.extractBearerToken(req, res);
217
+ if (!token)
218
+ return; // response already sent
219
+ const publicKey = await this.getPublicKeyFromJwks(token);
220
+ const algorithms = this.options.jwks.algorithms || ["RS256"];
221
+ const decoded = jwt.verify(token, publicKey, { algorithms });
222
+ const accessRules = Array.isArray(decoded?.accessRules)
223
+ ? decoded.accessRules
224
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
225
+ ? [decoded.pathFilter]
226
+ : undefined);
227
+ const allowed = Array.isArray(accessRules)
228
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
229
+ : false;
230
+ if (!allowed) {
231
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
232
+ }
233
+ handler(req, res);
234
+ }
235
+ catch (err) {
236
+ return res.status(401).json({ error: "Invalid token" });
237
+ }
238
+ });
239
+ }
240
+ else if (this.options?.jwtSecret) {
241
+ this.app.router.get(fullPath, (req, res) => {
242
+ const authHeader = req.headers["authorization"];
243
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
244
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
245
+ }
246
+ const token = authHeader.slice(7);
247
+ try {
248
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
249
+ const accessRules = Array.isArray(decoded?.accessRules)
250
+ ? decoded.accessRules
251
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
252
+ ? [decoded.pathFilter]
253
+ : undefined);
254
+ const allowed = Array.isArray(accessRules)
255
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
256
+ : false;
257
+ if (!allowed) {
258
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
259
+ }
260
+ handler(req, res);
261
+ }
262
+ catch (err) {
263
+ return res.status(401).json({ error: "Invalid token" });
264
+ }
265
+ });
266
+ }
267
+ else {
268
+ this.app.router.get(fullPath, handler);
269
+ }
270
+ if (this.app.swaggerSpec) {
271
+ const queryParams = options?.queryParams || [];
272
+ const canonicalParams = queryParams.reduce((acc, param) => {
273
+ if (typeof param.chatCanonical === "string" && param.chatCanonical.trim().length) {
274
+ acc[param.chatCanonical.trim()] = param.name;
275
+ }
276
+ return acc;
277
+ }, {});
278
+ const chatDefaults = {};
279
+ for (const param of queryParams) {
280
+ if (param.defaultValue !== undefined) {
281
+ chatDefaults[param.name] = param.defaultValue;
282
+ }
283
+ }
284
+ const optionDefaults = options?.chatDefaults ?? {};
285
+ for (const [key, value] of Object.entries(optionDefaults)) {
286
+ if (value !== undefined) {
287
+ chatDefaults[key] = value;
288
+ }
289
+ }
290
+ const unsChatMeta = Object.keys(canonicalParams).length || Object.keys(chatDefaults).length
291
+ ? {
292
+ canonicalParams,
293
+ defaults: chatDefaults,
294
+ }
295
+ : null;
296
+ this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
297
+ this.app.swaggerSpec.paths[apiPath] = {
298
+ get: {
299
+ summary: options?.apiDescription || "No description",
300
+ tags: options?.tags || [],
301
+ parameters: queryParams.map((p) => ({
302
+ name: p.name,
303
+ in: "query",
304
+ required: !!p.required,
305
+ schema: {
306
+ type: p.type,
307
+ ...(p.defaultValue !== undefined ? { default: p.defaultValue } : {}),
308
+ },
309
+ description: p.description,
310
+ ...(p.chatCanonical ? { "x-uns-chat-canonical": p.chatCanonical } : {}),
311
+ })),
312
+ ...(unsChatMeta ? { "x-uns-chat": unsChatMeta } : {}),
313
+ responses: {
314
+ "200": { description: "OK" },
315
+ "400": { description: "Bad Request" },
316
+ "401": { description: "Unauthorized" },
317
+ "403": { description: "Forbidden" },
318
+ },
319
+ },
320
+ };
321
+ }
322
+ }
323
+ catch (error) {
324
+ logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
325
+ }
326
+ }
327
+ /**
328
+ * Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
329
+ * Does not create individual API attribute nodes; the controller treats this as a fallback.
330
+ *
331
+ * This is intended for use by the uns-api-global microservice, which acts as a
332
+ * catch-all gateway for an entire topic namespace. Regular microservices should
333
+ * NOT call this — use registerGetEndpoint() for individual attribute endpoints instead.
334
+ */
335
+ async registerCatchAll(topicPrefix, options) {
336
+ while (this.app.server.listening === false) {
337
+ await new Promise((resolve) => setTimeout(resolve, 100));
338
+ }
339
+ const finalOptions = options ?? {};
340
+ const topicNormalized = topicPrefix.endsWith("/") ? topicPrefix : `${topicPrefix}`;
341
+ const apiBase = typeof finalOptions?.apiBase === "string" && finalOptions.apiBase.length
342
+ ? finalOptions.apiBase
343
+ : this.getPublishedApiHost();
344
+ const apiBasePath = typeof finalOptions?.apiBasePath === "string" && finalOptions.apiBasePath.length
345
+ ? finalOptions.apiBasePath
346
+ : "/api";
347
+ const swaggerPath = typeof finalOptions?.swaggerPath === "string" && finalOptions.swaggerPath.length
348
+ ? finalOptions.swaggerPath
349
+ : `/${this.processName}/${this.instanceName}/catchall-swagger.json`;
350
+ const normalizedSwaggerPath = swaggerPath.startsWith("/") ? swaggerPath : `/${swaggerPath}`;
351
+ const swaggerDoc = finalOptions.swaggerDoc ||
352
+ {
353
+ openapi: "3.0.0",
354
+ info: {
355
+ title: "Catch-all API",
356
+ version: "1.0.0",
357
+ },
358
+ paths: {
359
+ "/api/{topicPath}": {
360
+ get: {
361
+ summary: finalOptions.apiDescription || "Catch-all handler",
362
+ tags: finalOptions.tags || [],
363
+ parameters: [
364
+ {
365
+ name: "topicPath",
366
+ in: "path",
367
+ required: true,
368
+ schema: { type: "string" },
369
+ description: "Resolved UNS topic path",
370
+ },
371
+ ...(finalOptions.queryParams || []).map((p) => ({
372
+ name: p.name,
373
+ in: "query",
374
+ required: !!p.required,
375
+ schema: { type: p.type },
376
+ description: p.description,
377
+ })),
378
+ ],
379
+ responses: {
380
+ "200": { description: "OK" },
381
+ "400": { description: "Bad Request" },
382
+ "401": { description: "Unauthorized" },
383
+ "403": { description: "Forbidden" },
384
+ },
385
+ },
386
+ },
387
+ },
388
+ };
389
+ this.app.registerSwaggerDoc(normalizedSwaggerPath, swaggerDoc);
390
+ logger.info(`${this.instanceNameWithSuffix} - Catch-all Swagger available at ${normalizedSwaggerPath} (target ${apiBase.replace(/\/+$/, "")}${normalizedSwaggerPath})`);
391
+ if (!this.catchAllRouteRegistered) {
392
+ this.app.router.use((req, res) => {
393
+ const topicPath = (req.path ?? "").replace(/^\/+/, "");
394
+ req.params = { ...(req.params || {}), topicPath };
395
+ this.event.emit("apiGetEvent", { req, res });
396
+ });
397
+ this.catchAllRouteRegistered = true;
398
+ }
399
+ this.registerApiCatchAll({
400
+ topic: topicNormalized,
401
+ apiBase,
402
+ apiBasePath,
403
+ swaggerPath,
404
+ });
405
+ }
406
+ async post(topic, asset, objectType, objectId, attribute, options) {
407
+ await this.registerMutationEndpoint("POST", topic, asset, objectType, objectId, attribute, options);
408
+ }
409
+ async put(topic, asset, objectType, objectId, attribute, options) {
410
+ await this.registerMutationEndpoint("PUT", topic, asset, objectType, objectId, attribute, options);
411
+ }
412
+ async patch(topic, asset, objectType, objectId, attribute, options) {
413
+ await this.registerMutationEndpoint("PATCH", topic, asset, objectType, objectId, attribute, options);
414
+ }
415
+ async delete(topic, asset, objectType, objectId, attribute, options) {
416
+ await this.registerMutationEndpoint("DELETE", topic, asset, objectType, objectId, attribute, options);
417
+ }
418
+ async registerMutationEndpoint(method, topic, asset, objectType, objectId, attribute, options) {
419
+ while (this.app.server.listening === false) {
420
+ await new Promise((resolve) => setTimeout(resolve, 100));
421
+ }
422
+ const time = UnsPacket.formatToISO8601(new Date());
423
+ const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
424
+ const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
425
+ const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
426
+ const methodKey = method.toLowerCase();
427
+ try {
428
+ this.registerApiEndpoint({
429
+ timestamp: time,
430
+ topic,
431
+ attribute,
432
+ routeOnly: options?.routeOnly === true,
433
+ registryTopic: options?.registryTopic ??
434
+ "api-endpoints",
435
+ apiHost: this.getPublishedApiHost(),
436
+ apiEndpoint: apiPath,
437
+ apiMethod: method,
438
+ apiQueryParams: [],
439
+ apiDescription: options?.apiDescription,
440
+ serviceApi: options?.serviceApi ?? null,
441
+ attributeType: UnsAttributeType.Api,
442
+ apiSwaggerEndpoint: swaggerPath,
443
+ asset,
444
+ objectType,
445
+ objectId,
446
+ });
447
+ const handler = (req, res) => {
448
+ this.event.emit(this.getApiEventName(method), { req, res });
449
+ };
450
+ const routerMethod = this.app.router[methodKey].bind(this.app.router);
451
+ if (this.options?.jwks?.wellKnownJwksUrl) {
452
+ routerMethod(fullPath, async (req, res) => {
453
+ try {
454
+ const token = this.extractBearerToken(req, res);
455
+ if (!token)
456
+ return;
457
+ const publicKey = await this.getPublicKeyFromJwks(token);
458
+ const algorithms = this.options.jwks.algorithms || ["RS256"];
459
+ const decoded = jwt.verify(token, publicKey, { algorithms });
460
+ const accessRules = Array.isArray(decoded?.accessRules)
461
+ ? decoded.accessRules
462
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
463
+ ? [decoded.pathFilter]
464
+ : undefined);
465
+ const allowed = Array.isArray(accessRules)
466
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
467
+ : false;
468
+ if (!allowed) {
469
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
470
+ }
471
+ handler(req, res);
472
+ }
473
+ catch (_err) {
474
+ return res.status(401).json({ error: "Invalid token" });
475
+ }
476
+ });
477
+ }
478
+ else if (this.options?.jwtSecret) {
479
+ routerMethod(fullPath, (req, res) => {
480
+ const authHeader = req.headers["authorization"];
481
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
482
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
483
+ }
484
+ const token = authHeader.slice(7);
485
+ try {
486
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
487
+ const accessRules = Array.isArray(decoded?.accessRules)
488
+ ? decoded.accessRules
489
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
490
+ ? [decoded.pathFilter]
491
+ : undefined);
492
+ const allowed = Array.isArray(accessRules)
493
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
494
+ : false;
495
+ if (!allowed) {
496
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
497
+ }
498
+ handler(req, res);
499
+ }
500
+ catch (_err) {
501
+ return res.status(401).json({ error: "Invalid token" });
502
+ }
503
+ });
504
+ }
505
+ else {
506
+ routerMethod(fullPath, handler);
507
+ }
508
+ if (this.app.swaggerSpec) {
509
+ const requestBody = options?.requestBody;
510
+ this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
511
+ this.app.swaggerSpec.paths[apiPath] = this.app.swaggerSpec.paths[apiPath] || {};
512
+ this.app.swaggerSpec.paths[apiPath][methodKey] = {
513
+ summary: options?.apiDescription || "No description",
514
+ tags: options?.tags || [],
515
+ ...(requestBody
516
+ ? {
517
+ requestBody: {
518
+ description: requestBody.description,
519
+ required: requestBody.required ?? true,
520
+ content: {
521
+ "application/json": {
522
+ schema: requestBody.schema ?? { type: "object" },
523
+ },
524
+ },
525
+ },
526
+ }
527
+ : {}),
528
+ responses: {
529
+ "200": { description: "OK" },
530
+ "400": { description: "Bad Request" },
531
+ "401": { description: "Unauthorized" },
532
+ "403": { description: "Forbidden" },
533
+ },
534
+ };
535
+ }
536
+ }
537
+ catch (error) {
538
+ logger.error(`${this.instanceNameWithSuffix} - Error registering ${method} route ${fullPath}: ${error.message}`);
539
+ }
540
+ }
541
+ registerDataOffer(input) {
542
+ const offerId = typeof input.offerId === "string" ? input.offerId.trim() : "";
543
+ const displayName = typeof input.displayName === "string" ? input.displayName.trim() : "";
544
+ if (!offerId || !displayName) {
545
+ throw new Error("Data catalog offer requires non-empty offerId and displayName.");
546
+ }
547
+ const offerSchemas = (Array.isArray(input.schemas) ? input.schemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
548
+ const offerSchemaIndex = new Map(offerSchemas.map((schema) => [schema.id, schema]));
549
+ const normalizedOperations = (Array.isArray(input.operations) ? input.operations : [])
550
+ .map((operation, index) => this.normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex))
551
+ .filter((operation) => Boolean(operation));
552
+ if (!normalizedOperations.length) {
553
+ throw new Error(`Data catalog offer ${offerId} requires at least one operation.`);
554
+ }
555
+ const basePaths = Array.from(new Set([
556
+ ...(Array.isArray(input.basePaths) ? input.basePaths.map((entry) => normalizePathValue(entry)) : []),
557
+ ...normalizedOperations.map((operation) => {
558
+ const segments = operation.path.split("/").filter(Boolean);
559
+ if (segments.length <= 1) {
560
+ return operation.path;
561
+ }
562
+ return `/${segments.slice(0, -1).join("/")}`;
563
+ }),
564
+ ].filter((value) => Boolean(value))));
565
+ this.publishDataCatalogOffer({
566
+ offerId,
567
+ displayName,
568
+ description: typeof input.description === "string" ? input.description.trim() || null : null,
569
+ owner: typeof input.owner === "string" ? input.owner.trim() || null : null,
570
+ status: typeof input.status === "string" && input.status.trim() ? input.status.trim() : "available",
571
+ tags: normalizeStringArray(input.tags),
572
+ categories: normalizeStringArray(input.categories),
573
+ microserviceName: typeof input.microserviceName === "string" && input.microserviceName.trim()
574
+ ? input.microserviceName.trim()
575
+ : this.processName,
576
+ version: typeof input.version === "string" && input.version.trim() ? input.version.trim() : packageJson.version,
577
+ swaggerPath: normalizePathValue(input.swaggerPath),
578
+ basePaths,
579
+ operations: normalizedOperations,
580
+ schemas: offerSchemas,
581
+ metadata: input.metadata ?? null,
582
+ packageName: packageJson.name,
583
+ processName: this.processName,
584
+ processVersion: packageJson.version,
585
+ instanceName: this.instanceName,
586
+ controllerName: process.env["UNS_CONTROLLER_NAME"] ?? null,
587
+ controllerPublicBase: process.env["UNS_CONTROLLER_PUBLIC_BASE"] ?? process.env["UNS_PUBLIC_BASE"] ?? null,
588
+ });
589
+ }
590
+ emitStatusMetrics() {
591
+ const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
592
+ // Process-level status
593
+ this.event.emit("mqttProxyStatus", {
594
+ event: "uptime",
595
+ value: uptimeMinutes,
596
+ uom: PhysicalMeasurements.Minute,
597
+ statusTopic: this.processStatusTopic + "uptime",
598
+ });
599
+ this.event.emit("mqttProxyStatus", {
600
+ event: "alive",
601
+ value: 1,
602
+ uom: DataSizeMeasurements.Bit,
603
+ statusTopic: this.processStatusTopic + "alive",
604
+ });
605
+ // Instance-level status
606
+ this.event.emit("mqttProxyStatus", {
607
+ event: "uptime",
608
+ value: uptimeMinutes,
609
+ uom: PhysicalMeasurements.Minute,
610
+ statusTopic: this.instanceStatusTopic + "uptime",
611
+ });
612
+ this.event.emit("mqttProxyStatus", {
613
+ event: "alive",
614
+ value: 1,
615
+ uom: DataSizeMeasurements.Bit,
616
+ statusTopic: this.instanceStatusTopic + "alive",
617
+ });
618
+ this.emitDataCatalogOffers();
619
+ }
620
+ registerHealthEndpoint() {
621
+ const routePath = "/status";
622
+ this.app.router.get(routePath, (_req, res) => {
623
+ res.json({
624
+ alive: true,
625
+ processName: this.processName,
626
+ instanceName: this.instanceName,
627
+ package: packageJson.name,
628
+ version: packageJson.version,
629
+ startedAt: new Date(this.startedAt).toISOString(),
630
+ uptimeMs: Date.now() - this.startedAt,
631
+ timestamp: new Date().toISOString(),
632
+ });
633
+ });
634
+ if (this.app.swaggerSpec) {
635
+ this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
636
+ const swaggerPath = `${this.apiBasePrefix}${routePath}`.replace(/\/{2,}/g, "/");
637
+ this.app.swaggerSpec.paths[swaggerPath] = {
638
+ get: {
639
+ summary: "Health status",
640
+ responses: {
641
+ "200": { description: "OK" },
642
+ },
643
+ },
644
+ };
645
+ }
646
+ }
647
+ getApiEventName(method) {
648
+ switch (method) {
649
+ case "GET":
650
+ return "apiGetEvent";
651
+ case "POST":
652
+ return "apiPostEvent";
653
+ case "PUT":
654
+ return "apiPutEvent";
655
+ case "PATCH":
656
+ return "apiPatchEvent";
657
+ case "DELETE":
658
+ return "apiDeleteEvent";
659
+ }
660
+ }
661
+ extractBearerToken(req, res) {
662
+ const authHeader = req.headers["authorization"];
663
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
664
+ res.status(401).json({ error: "Missing or invalid Authorization header" });
665
+ return undefined;
666
+ }
667
+ return authHeader.slice(7);
668
+ }
669
+ async getPublicKeyFromJwks(token) {
670
+ // Decode header to get kid
671
+ const decoded = jwt.decode(token, { complete: true });
672
+ const kid = decoded?.header?.kid;
673
+ const keys = await this.fetchJwksKeys();
674
+ let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
675
+ // If no kid match and activeKidUrl configured, try that
676
+ if (!jwk && this.options?.jwks?.activeKidUrl) {
677
+ try {
678
+ const resp = await fetch(this.options.jwks.activeKidUrl);
679
+ if (resp.ok) {
680
+ const activeKid = await resp.text();
681
+ jwk = keys.find((k) => k.kid === activeKid.trim());
682
+ }
683
+ }
684
+ catch (_) {
685
+ // ignore and fall through
686
+ }
687
+ }
688
+ // If still not found but only one key, use it
689
+ if (!jwk && keys.length === 1) {
690
+ jwk = keys[0];
691
+ }
692
+ if (!jwk) {
693
+ throw new Error("Signing key not found in JWKS");
694
+ }
695
+ // Prefer x5c certificate if provided
696
+ if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
697
+ return this.certFromX5c(jwk.x5c[0]);
698
+ }
699
+ // Build PEM from JWK (RSA)
700
+ if (jwk.kty === "RSA" && jwk.n && jwk.e) {
701
+ const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
702
+ return keyObj.export({ type: "spki", format: "pem" }).toString();
703
+ }
704
+ throw new Error("Unsupported JWK format");
705
+ }
706
+ async fetchJwksKeys() {
707
+ const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
708
+ const now = Date.now();
709
+ if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
710
+ return this.jwksCache.keys;
711
+ }
712
+ const url = this.options.jwks.wellKnownJwksUrl;
713
+ const resp = await fetch(url);
714
+ if (!resp.ok) {
715
+ throw new Error(`Failed to fetch JWKS (${resp.status})`);
716
+ }
717
+ const body = await resp.json();
718
+ const keys = Array.isArray(body?.keys) ? body.keys : [];
719
+ this.jwksCache = { keys, fetchedAt: now };
720
+ return keys;
721
+ }
722
+ certFromX5c(x5cFirst) {
723
+ const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
724
+ return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
725
+ }
726
+ async stop() {
727
+ if (this.statusInterval) {
728
+ clearInterval(this.statusInterval);
729
+ this.statusInterval = null;
730
+ }
731
+ await super.stop();
732
+ }
733
+ publishDataCatalogOffer(offer) {
734
+ const offerId = typeof offer.offerId === "string" ? offer.offerId.trim() : "";
735
+ if (!offerId) {
736
+ return;
737
+ }
738
+ this.dataCatalogOffers.set(offerId, offer);
739
+ this.emitDataCatalogOffers();
740
+ logger.info(`${this.instanceNameWithSuffix} - Registered data catalog offer: ${offerId}`);
741
+ }
742
+ emitDataCatalogOffers() {
743
+ if (this.instanceStatusTopic === "" || this.dataCatalogOffers.size === 0) {
744
+ return;
745
+ }
746
+ this.event.emit("unsProxyProducedDataCatalogOffers", {
747
+ producedDataCatalogOffers: [...this.dataCatalogOffers.values()],
748
+ statusTopic: this.instanceStatusTopic + "data-catalog-offers",
749
+ });
750
+ }
751
+ normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex) {
752
+ const method = typeof operation.method === "string" ? operation.method.trim().toUpperCase() : "";
753
+ const path = normalizePathValue(operation.path);
754
+ if (!method || !path) {
755
+ return null;
756
+ }
757
+ return {
758
+ id: typeof operation.id === "string" && operation.id.trim() ? operation.id.trim() : `${offerId}-${method.toLowerCase()}-${index + 1}`,
759
+ method,
760
+ path,
761
+ summary: typeof operation.summary === "string" ? operation.summary.trim() || null : null,
762
+ description: typeof operation.description === "string" ? operation.description.trim() || null : null,
763
+ tags: normalizeStringArray(operation.tags),
764
+ deprecated: operation.deprecated === true,
765
+ parameters: (Array.isArray(operation.parameters) ? operation.parameters : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
766
+ headers: (Array.isArray(operation.headers) ? operation.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
767
+ requestBody: operation.requestBody ? this.normalizeDataOfferRequestBody(operation.requestBody, offerSchemaIndex) : null,
768
+ responses: (Array.isArray(operation.responses) ? operation.responses : []).map((response) => this.normalizeDataOfferResponse(response, offerSchemaIndex)),
769
+ };
770
+ }
771
+ normalizeDataOfferParameter(parameter) {
772
+ return {
773
+ name: typeof parameter.name === "string" ? parameter.name.trim() : "",
774
+ in: typeof parameter.in === "string" ? parameter.in.trim() : "query",
775
+ required: parameter.required === true,
776
+ description: typeof parameter.description === "string" ? parameter.description.trim() || null : null,
777
+ type: typeof parameter.type === "string" && parameter.type.trim() ? parameter.type.trim() : "string",
778
+ format: typeof parameter.format === "string" ? parameter.format.trim() || null : null,
779
+ nullable: parameter.nullable === true,
780
+ example: parameter.example ?? null,
781
+ enumValues: normalizeStringArray(parameter.enumValues),
782
+ schema: parameter.schema ?? null,
783
+ };
784
+ }
785
+ normalizeDataOfferRequestBody(requestBody, offerSchemaIndex) {
786
+ return {
787
+ required: requestBody.required === true,
788
+ description: typeof requestBody.description === "string" ? requestBody.description.trim() || null : null,
789
+ contentType: typeof requestBody.contentType === "string" ? requestBody.contentType.trim() || null : null,
790
+ schemas: this.resolveOfferSchemas(requestBody.schemas, requestBody.schemaIds, offerSchemaIndex),
791
+ };
792
+ }
793
+ normalizeDataOfferResponse(response, offerSchemaIndex) {
794
+ return {
795
+ statusCode: typeof response.statusCode === "string" ? response.statusCode.trim() : "",
796
+ description: typeof response.description === "string" ? response.description.trim() || null : null,
797
+ contentType: typeof response.contentType === "string" ? response.contentType.trim() || null : null,
798
+ schemas: this.resolveOfferSchemas(response.schemas, response.schemaIds, offerSchemaIndex),
799
+ examplePayloads: Array.isArray(response.examplePayloads) ? response.examplePayloads : [],
800
+ headers: (Array.isArray(response.headers) ? response.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
801
+ };
802
+ }
803
+ normalizeDataOfferSchema(schema) {
804
+ return {
805
+ id: typeof schema.id === "string" ? schema.id.trim() : "",
806
+ title: typeof schema.title === "string" ? schema.title.trim() : "",
807
+ kind: typeof schema.kind === "string" && schema.kind.trim() ? schema.kind.trim() : "schema",
808
+ source: typeof schema.source === "string" && schema.source.trim() ? schema.source.trim() : "registered",
809
+ contentType: typeof schema.contentType === "string" ? schema.contentType.trim() || null : null,
810
+ rootType: typeof schema.rootType === "string" && schema.rootType.trim() ? schema.rootType.trim() : "object",
811
+ nullable: schema.nullable === true,
812
+ description: typeof schema.description === "string" ? schema.description.trim() || null : null,
813
+ fields: (Array.isArray(schema.fields) ? schema.fields : []).map((field) => this.normalizeDataOfferSchemaField(field)),
814
+ examplePayloads: Array.isArray(schema.examplePayloads) ? schema.examplePayloads : [],
815
+ };
816
+ }
817
+ normalizeDataOfferSchemaField(field) {
818
+ const defaultName = typeof field.name === "string" ? field.name.trim() : "";
819
+ const path = typeof field.path === "string" && field.path.trim() ? field.path.trim() : defaultName;
820
+ return {
821
+ path,
822
+ name: defaultName || (path.split(".").at(-1) ?? path),
823
+ type: typeof field.type === "string" && field.type.trim() ? field.type.trim() : "string",
824
+ format: typeof field.format === "string" ? field.format.trim() || null : null,
825
+ nullable: field.nullable === true,
826
+ required: field.required === true,
827
+ description: typeof field.description === "string" ? field.description.trim() || null : null,
828
+ enumValues: normalizeStringArray(field.enumValues),
829
+ example: field.example ?? null,
830
+ };
831
+ }
832
+ resolveOfferSchemas(inlineSchemas, schemaIds, offerSchemaIndex) {
833
+ const result = (Array.isArray(inlineSchemas) ? inlineSchemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
834
+ for (const schemaId of Array.isArray(schemaIds) ? schemaIds : []) {
835
+ const normalizedId = typeof schemaId === "string" ? schemaId.trim() : "";
836
+ if (!normalizedId) {
837
+ continue;
838
+ }
839
+ const resolved = offerSchemaIndex.get(normalizedId);
840
+ if (resolved && !result.some((schema) => schema.id === resolved.id)) {
841
+ result.push(resolved);
842
+ }
843
+ }
844
+ return result;
845
+ }
846
+ }