@uns-kit/api 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Aljoša Vister
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @uns-kit/api
2
+
3
+ `@uns-kit/api` exposes Express-based HTTP endpoints for UNS deployments. The plugin attaches a `createApiProxy` method to `UnsProxyProcess`, handles JWT/JWKS access control, and automatically publishes API metadata back into the Unified Namespace.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @uns-kit/api
9
+ # or
10
+ npm install @uns-kit/api
11
+ ```
12
+
13
+ Make sure `@uns-kit/core` is also installed; the plugin augments its runtime types.
14
+
15
+ ## Example
16
+
17
+ ```ts
18
+ import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
19
+ import unsApiPlugin, { type UnsProxyProcessWithApi } from "@uns-kit/api";
20
+
21
+ const process = new UnsProxyProcess("mqtt-broker:1883", { processName: "api-gateway" }) as UnsProxyProcessWithApi;
22
+ unsApiPlugin;
23
+
24
+ const api = await process.createApiProxy("gateway", { jwtSecret: "super-secret" });
25
+ await api.get("factory/", "status", {
26
+ apiDescription: "Factory status endpoint",
27
+ tags: ["status"],
28
+ });
29
+ ```
30
+
31
+ ## Scripts
32
+
33
+ ```bash
34
+ pnpm run typecheck
35
+ pnpm run build
36
+ ```
37
+
38
+ `build` emits both JavaScript and type declarations to `dist/`.
39
+
40
+ ## License
41
+
42
+ MIT © Aljoša Vister
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
2
+ import unsApiPlugin from "./uns-api-plugin";
3
+ import { ConfigFile } from "@uns-kit/core/config-file";
4
+ /**
5
+ * Load the configuration from a file.
6
+ * On the server, this file is provided by the `uns-datahub-controller`.
7
+ * In the development environment, you are responsible for creating and maintaining this file and its contents.
8
+ */
9
+ const config = await ConfigFile.loadConfig();
10
+ /**
11
+ * Connect to the API proxy process
12
+ */
13
+ const unsProxyProcess = new UnsProxyProcess(config.infra.host, { processName: config.uns.processName });
14
+ unsApiPlugin;
15
+ const apiOptions = config.uns?.jwksWellKnownUrl
16
+ ? {
17
+ jwks: {
18
+ wellKnownJwksUrl: config.uns.jwksWellKnownUrl,
19
+ activeKidUrl: config.uns.kidWellKnownUrl,
20
+ },
21
+ }
22
+ : {
23
+ jwtSecret: "CHANGEME",
24
+ };
25
+ const apiInput = await unsProxyProcess.createApiProxy("templateUnsApiInput", apiOptions);
26
+ /**
27
+ * Register an API endpoint and event handler
28
+ */
29
+ apiInput.get("sij/", "summary-1", {
30
+ tags: ["Tag1"],
31
+ apiDescription: "Test API endpoint 1",
32
+ queryParams: [
33
+ { name: "filter", type: "string", required: true, description: "Filter za podatke" },
34
+ { name: "limit", type: "number", required: false, description: "Koliko podatkov želiš" },
35
+ ]
36
+ });
37
+ apiInput.get("sij/", "summary-2", {
38
+ tags: ["Tag2"],
39
+ apiDescription: "Test API endpoint 2",
40
+ queryParams: [
41
+ { name: "filter", type: "string", required: true, description: "Filter za podatke" },
42
+ { name: "limit", type: "number", required: false, description: "Koliko podatkov želiš" },
43
+ ]
44
+ });
45
+ apiInput.event.on("apiGetEvent", (event) => {
46
+ try {
47
+ const appContext = event.req.appContext;
48
+ // Add SQL query or any other code here
49
+ event.res.send("OK");
50
+ }
51
+ catch (error) {
52
+ event.res.status(400).send("Error");
53
+ }
54
+ });
@@ -0,0 +1 @@
1
+ export type { IApiProxyOptions, IGetEndpointOptions, QueryParamDef } from "@uns-kit/core/uns/uns-interfaces";
@@ -0,0 +1 @@
1
+ export {};
package/dist/app.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Module dependencies.
3
+ */
4
+ import { type Router } from "express";
5
+ import * as http from "http";
6
+ export default class App {
7
+ private expressApplication;
8
+ server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
9
+ private port;
10
+ router: Router;
11
+ private processName;
12
+ private instanceName;
13
+ swaggerSpec: {
14
+ openapi: string;
15
+ info: {
16
+ title: string;
17
+ version: string;
18
+ };
19
+ paths: Record<string, any>;
20
+ };
21
+ constructor(port: number, processName: string, instanceName: string, appContext?: any);
22
+ static getExternalIPv4(): string | null;
23
+ getSwaggerSpec(): {
24
+ openapi: string;
25
+ info: {
26
+ title: string;
27
+ version: string;
28
+ };
29
+ paths: Record<string, any>;
30
+ };
31
+ start(): Promise<void>;
32
+ }
package/dist/app.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Module dependencies.
3
+ */
4
+ import express from "express";
5
+ import * as http from "http";
6
+ import * as path from "path";
7
+ import cookieParser from "cookie-parser";
8
+ import { basePath } from "@uns-kit/core/base-path";
9
+ import logger from "@uns-kit/core/logger";
10
+ import os from 'os';
11
+ export default class App {
12
+ expressApplication;
13
+ server;
14
+ port;
15
+ router;
16
+ processName;
17
+ instanceName;
18
+ swaggerSpec;
19
+ constructor(port, processName, instanceName, appContext) {
20
+ this.router = express.Router();
21
+ this.port = port;
22
+ this.expressApplication = express();
23
+ this.server = http.createServer(this.expressApplication);
24
+ this.processName = processName;
25
+ this.instanceName = instanceName;
26
+ // Add context
27
+ this.expressApplication.use((req, _res, next) => {
28
+ req.appContext = appContext;
29
+ next();
30
+ });
31
+ // Body parser (req.body)
32
+ this.expressApplication.use(express.json());
33
+ this.expressApplication.use(express.urlencoded({ extended: false }));
34
+ // Add cookie parser
35
+ this.expressApplication.use(cookieParser());
36
+ // Static / public folder
37
+ const publicHome = process.env.PUBLIC_HOME === null || process.env.PUBLIC_HOME === undefined
38
+ ? "public"
39
+ : process.env.PUBLIC_HOME;
40
+ this.expressApplication.use(express.static(path.join(basePath, publicHome)));
41
+ // Map routes
42
+ this.router.use((_req, _res, next) => {
43
+ logger.info("Time: ", Date.now());
44
+ next();
45
+ });
46
+ this.expressApplication.use("/api", this.router);
47
+ // Swagger specs
48
+ this.swaggerSpec = {
49
+ openapi: "3.0.0",
50
+ info: {
51
+ title: "UNS API",
52
+ version: "1.0.0",
53
+ },
54
+ paths: {},
55
+ };
56
+ }
57
+ static getExternalIPv4() {
58
+ const interfaces = os.networkInterfaces();
59
+ for (const name of Object.keys(interfaces)) {
60
+ for (const iface of interfaces[name] || []) {
61
+ if (iface.family === 'IPv4' && !iface.internal) {
62
+ return iface.address;
63
+ }
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ getSwaggerSpec() {
69
+ return this.swaggerSpec;
70
+ }
71
+ async start() {
72
+ // Listen on provided port, on all network interfaces.
73
+ this.server.listen(this.port);
74
+ this.server.on("error", (error) => {
75
+ if (error.syscall !== "listen") {
76
+ throw error;
77
+ }
78
+ const bind = typeof this.port === "string"
79
+ ? `Pipe ${this.port}`
80
+ : `Port ${this.port}`;
81
+ // handle specific listen errors with friendly messages
82
+ switch (error.code) {
83
+ case "EACCES":
84
+ logger.error(`${bind} requires elevated privileges`);
85
+ process.exit(1);
86
+ break;
87
+ case "EADDRINUSE":
88
+ logger.error(`${bind} is already in use`);
89
+ process.exit(1);
90
+ break;
91
+ default:
92
+ throw error;
93
+ }
94
+ });
95
+ this.server.on("listening", () => {
96
+ App.bind(this);
97
+ const addressInfo = this.server.address();
98
+ let ip;
99
+ let port;
100
+ if (addressInfo && typeof addressInfo === "object") {
101
+ ip = App.getExternalIPv4();
102
+ port = addressInfo.port;
103
+ }
104
+ else if (typeof addressInfo === "string") {
105
+ ip = App.getExternalIPv4();
106
+ port = "";
107
+ }
108
+ logger.info(`API listening on http://${ip}:${port}/api`);
109
+ logger.info(`Swagger openAPI on http://${ip}:${port}/${this.processName}/${this.instanceName}/swagger.json`);
110
+ this.expressApplication.get(`/${this.processName}/${this.instanceName}/swagger.json`, (req, res) => res.json(this.getSwaggerSpec()));
111
+ });
112
+ }
113
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from "./uns-api-plugin.js";
2
+ export { UnsApiProxy, type UnsProxyProcessWithApi } from "./uns-api-plugin.js";
3
+ export * from "./api-interfaces.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default } from "./uns-api-plugin.js";
2
+ export { UnsApiProxy } from "./uns-api-plugin.js";
3
+ export * from "./api-interfaces.js";
@@ -0,0 +1,6 @@
1
+ import { type Router } from "express";
2
+ export default class Api {
3
+ router: Router;
4
+ private upload;
5
+ constructor();
6
+ }
@@ -0,0 +1,48 @@
1
+ import express from "express";
2
+ import multer from "multer";
3
+ import logger from "@uns-kit/core/logger";
4
+ export default class Api {
5
+ router;
6
+ upload;
7
+ constructor() {
8
+ const storage = multer.diskStorage({
9
+ destination: function (req, file, cb) {
10
+ cb(null, "tmp/");
11
+ },
12
+ filename: function (req, file, cb) {
13
+ const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
14
+ cb(null, file.fieldname + "-" + uniqueSuffix + ".jpg");
15
+ },
16
+ });
17
+ this.upload = multer({ storage: storage });
18
+ this.router = express.Router();
19
+ this.router.use((req, res, next) => {
20
+ logger.info("Time: ", Date.now());
21
+ next();
22
+ });
23
+ /**
24
+ * Open for all
25
+ *
26
+ * Example post request
27
+ */
28
+ this.router.post("/call", async function (req, res) {
29
+ try {
30
+ const appContext = req.appContext;
31
+ res.send("OK");
32
+ }
33
+ catch (error) {
34
+ res.status(400).send("Error");
35
+ }
36
+ });
37
+ /**
38
+ * Open for all
39
+ *
40
+ * Upload files
41
+ */
42
+ this.router.post("/upload", this.upload.single("file"), function (req, res) {
43
+ const appContext = req.appContext;
44
+ logger.info(req.file);
45
+ res.send("Successfully uploaded files");
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,9 @@
1
+ import type { IApiProxyOptions } from "@uns-kit/core/uns/uns-interfaces";
2
+ import UnsProxyProcess, { type UnsProxyProcessPlugin } from "@uns-kit/core/uns/uns-proxy-process";
3
+ import UnsApiProxy from "./uns-api-proxy.js";
4
+ declare const unsApiPlugin: UnsProxyProcessPlugin;
5
+ export default unsApiPlugin;
6
+ export { UnsApiProxy };
7
+ export type UnsProxyProcessWithApi = UnsProxyProcess & {
8
+ createApiProxy(instanceName: string, options: IApiProxyOptions): Promise<UnsApiProxy>;
9
+ };
@@ -0,0 +1,38 @@
1
+ import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
2
+ import UnsApiProxy from "./uns-api-proxy.js";
3
+ const apiProxyRegistry = new WeakMap();
4
+ const getApiProxies = (instance) => {
5
+ let proxies = apiProxyRegistry.get(instance);
6
+ if (!proxies) {
7
+ proxies = [];
8
+ apiProxyRegistry.set(instance, proxies);
9
+ }
10
+ return proxies;
11
+ };
12
+ const unsApiPlugin = ({ define }) => {
13
+ define({
14
+ async createApiProxy(instanceName, options) {
15
+ await this.waitForProcessConnection();
16
+ const internals = this;
17
+ const unsApiProxy = new UnsApiProxy(internals.processName, instanceName, options);
18
+ unsApiProxy.event.on("unsProxyProducedTopics", (event) => {
19
+ internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedTopics), {
20
+ retain: true,
21
+ properties: { messageExpiryInterval: 120000 },
22
+ });
23
+ });
24
+ unsApiProxy.event.on("unsProxyProducedApiEndpoints", (event) => {
25
+ internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedApiEndpoints), {
26
+ retain: true,
27
+ properties: { messageExpiryInterval: 120000 },
28
+ });
29
+ });
30
+ internals.unsApiProxies.push(unsApiProxy);
31
+ getApiProxies(this).push(unsApiProxy);
32
+ return unsApiProxy;
33
+ },
34
+ });
35
+ };
36
+ UnsProxyProcess.use(unsApiPlugin);
37
+ export default unsApiPlugin;
38
+ export { UnsApiProxy };
@@ -0,0 +1,34 @@
1
+ import { UnsAttribute } from "@uns-kit/core/uns/uns-interfaces";
2
+ import UnsProxy from "@uns-kit/core/uns/uns-proxy";
3
+ import { UnsTopics } from "@uns-kit/core/uns/uns-topics";
4
+ import { IApiProxyOptions, IGetEndpointOptions } from "@uns-kit/core/uns/uns-interfaces";
5
+ export default class UnsApiProxy extends UnsProxy {
6
+ instanceName: string;
7
+ private topicBuilder;
8
+ private processName;
9
+ protected processStatusTopic: string;
10
+ private app;
11
+ private options;
12
+ private jwksCache?;
13
+ constructor(processName: string, instanceName: string, options: IApiProxyOptions);
14
+ /**
15
+ * Unregister endpoint
16
+ * @param topic - The API topic
17
+ * @param attribute - The attribute for the topic.
18
+ * @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
19
+ */
20
+ unregister(topic: UnsTopics, attribute: UnsAttribute, method: "GET" | "POST" | "PUT" | "DELETE"): Promise<void>;
21
+ /**
22
+ * Register a GET endpoint with optional JWT path filter.
23
+ * @param topic - The API topic
24
+ * @param attribute - The attribute for the topic.
25
+ * @param options.description - Optional description.
26
+ * @param options.tags - Optional tags.
27
+ */
28
+ get(topic: UnsTopics, attribute: UnsAttribute, options?: IGetEndpointOptions): Promise<void>;
29
+ post(..._args: any[]): any;
30
+ private extractBearerToken;
31
+ private getPublicKeyFromJwks;
32
+ private fetchJwksKeys;
33
+ private certFromX5c;
34
+ }
@@ -0,0 +1,290 @@
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";
6
+ import { UnsAttributeType } from "@uns-kit/core/graphql/schema";
7
+ import logger from "@uns-kit/core/logger";
8
+ import { MqttTopicBuilder } from "@uns-kit/core/uns-mqtt/mqtt-topic-builder";
9
+ import { UnsPacket } from "@uns-kit/core/uns/uns-packet";
10
+ import UnsProxy from "@uns-kit/core/uns/uns-proxy";
11
+ import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher";
12
+ import App from "./app.js";
13
+ const packageJsonPath = path.join(basePath, "package.json");
14
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
15
+ export default class UnsApiProxy extends UnsProxy {
16
+ instanceName;
17
+ topicBuilder;
18
+ processName;
19
+ processStatusTopic;
20
+ app;
21
+ options;
22
+ jwksCache;
23
+ constructor(processName, instanceName, options) {
24
+ super();
25
+ this.options = options;
26
+ this.app = new App(0, processName, instanceName);
27
+ this.app.start();
28
+ this.instanceName = instanceName;
29
+ this.processName = processName;
30
+ // Create the topic builder using packageJson values and the processName.
31
+ this.topicBuilder = new MqttTopicBuilder(`uns-infra/${packageJson.name}/${packageJson.version}/${processName}/`);
32
+ // Generate the processStatusTopic using the builder.
33
+ this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
34
+ // Derive the instanceStatusTopic by appending the instance name.
35
+ this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
36
+ // Concatenate processName with instanceName for the worker identification.
37
+ this.instanceNameWithSuffix = `${processName}-${instanceName}`;
38
+ }
39
+ /**
40
+ * Unregister endpoint
41
+ * @param topic - The API topic
42
+ * @param attribute - The attribute for the topic.
43
+ * @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
44
+ */
45
+ async unregister(topic, attribute, method) {
46
+ const fullPath = `/${topic}${attribute}`;
47
+ const apiPath = `/api${fullPath}`;
48
+ const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
49
+ // Remove route from router
50
+ if (this.app.router?.stack) {
51
+ this.app.router.stack = this.app.router.stack.filter((layer) => {
52
+ return !(layer.route &&
53
+ layer.route.path === fullPath &&
54
+ layer.route.methods[methodKey]);
55
+ });
56
+ }
57
+ // Remove from Swagger spec if path exists
58
+ if (this.app.swaggerSpec?.paths?.[apiPath]) {
59
+ delete this.app.swaggerSpec.paths[apiPath][methodKey];
60
+ // If no methods remain for the path, delete the whole path
61
+ if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
62
+ delete this.app.swaggerSpec.paths[apiPath];
63
+ }
64
+ }
65
+ // Unregister from internal endpoint tracking
66
+ this.unregisterApiEndpoint(topic, attribute);
67
+ }
68
+ /**
69
+ * Register a GET endpoint with optional JWT path filter.
70
+ * @param topic - The API topic
71
+ * @param attribute - The attribute for the topic.
72
+ * @param options.description - Optional description.
73
+ * @param options.tags - Optional tags.
74
+ */
75
+ async get(topic, attribute, options) {
76
+ // Wait until the API server is started
77
+ while (this.app.server.listening === false) {
78
+ await new Promise((resolve) => setTimeout(resolve, 100));
79
+ }
80
+ const time = UnsPacket.formatToISO8601(new Date());
81
+ try {
82
+ // Get ip and port from environment variables or defaults
83
+ const addressInfo = this.app.server.address();
84
+ let ip;
85
+ let port;
86
+ if (addressInfo && typeof addressInfo === "object") {
87
+ ip = App.getExternalIPv4();
88
+ port = addressInfo.port;
89
+ }
90
+ else if (typeof addressInfo === "string") {
91
+ ip = App.getExternalIPv4();
92
+ port = "";
93
+ }
94
+ this.registerApiEndpoint({
95
+ timestamp: time,
96
+ topic: topic,
97
+ attribute: attribute,
98
+ apiHost: `http://${ip}:${port}`,
99
+ apiEndpoint: `/api/${topic}${attribute}`,
100
+ apiMethod: "GET",
101
+ apiQueryParams: options.queryParams,
102
+ apiDescription: options?.apiDescription,
103
+ attributeType: UnsAttributeType.Api,
104
+ apiSwaggerEndpoint: `/${this.processName}/${this.instanceName}/swagger.json`,
105
+ });
106
+ const fullPath = `/${topic}${attribute}`;
107
+ const handler = (req, res) => {
108
+ // Query param validation
109
+ if (options?.queryParams) {
110
+ const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
111
+ if (missingParams.length > 0) {
112
+ return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
113
+ }
114
+ // Optional: cast types (basic)
115
+ for (const param of options.queryParams) {
116
+ const value = req.query[param.name];
117
+ if (value !== undefined) {
118
+ switch (param.type) {
119
+ case "number":
120
+ if (isNaN(Number(value))) {
121
+ return res.status(400).json({ error: `Query param ${param.name} must be a number` });
122
+ }
123
+ break;
124
+ case "boolean":
125
+ if (!["true", "false", "1", "0"].includes(String(value))) {
126
+ return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
127
+ }
128
+ break;
129
+ // string: no check
130
+ }
131
+ }
132
+ }
133
+ }
134
+ this.event.emit("apiGetEvent", { req, res });
135
+ };
136
+ // JWT or JWKS or open
137
+ if (this.options?.jwks?.wellKnownJwksUrl) {
138
+ this.app.router.get(fullPath, async (req, res) => {
139
+ try {
140
+ const token = this.extractBearerToken(req, res);
141
+ if (!token)
142
+ return; // response already sent
143
+ const publicKey = await this.getPublicKeyFromJwks(token);
144
+ const algorithms = this.options.jwks.algorithms || ["RS256"];
145
+ const decoded = jwt.verify(token, publicKey, { algorithms });
146
+ const accessRules = Array.isArray(decoded?.accessRules)
147
+ ? decoded.accessRules
148
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
149
+ ? [decoded.pathFilter]
150
+ : undefined);
151
+ const allowed = Array.isArray(accessRules)
152
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
153
+ : false;
154
+ if (!allowed) {
155
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
156
+ }
157
+ handler(req, res);
158
+ }
159
+ catch (err) {
160
+ return res.status(401).json({ error: "Invalid token" });
161
+ }
162
+ });
163
+ }
164
+ else if (this.options?.jwtSecret) {
165
+ this.app.router.get(fullPath, (req, res) => {
166
+ const authHeader = req.headers["authorization"];
167
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
168
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
169
+ }
170
+ const token = authHeader.slice(7);
171
+ try {
172
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
173
+ const accessRules = Array.isArray(decoded?.accessRules)
174
+ ? decoded.accessRules
175
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
176
+ ? [decoded.pathFilter]
177
+ : undefined);
178
+ const allowed = Array.isArray(accessRules)
179
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
180
+ : false;
181
+ if (!allowed) {
182
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
183
+ }
184
+ handler(req, res);
185
+ }
186
+ catch (err) {
187
+ return res.status(401).json({ error: "Invalid token" });
188
+ }
189
+ });
190
+ }
191
+ else {
192
+ this.app.router.get(fullPath, handler);
193
+ }
194
+ if (this.app.swaggerSpec) {
195
+ this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
196
+ this.app.swaggerSpec.paths[`/api${fullPath}`] = {
197
+ get: {
198
+ summary: options?.apiDescription || "No description",
199
+ tags: options?.tags || [],
200
+ parameters: (options?.queryParams || []).map((p) => ({
201
+ name: p.name,
202
+ in: "query",
203
+ required: !!p.required,
204
+ schema: { type: p.type },
205
+ description: p.description,
206
+ })),
207
+ responses: {
208
+ "200": { description: "OK" },
209
+ "400": { description: "Bad Request" },
210
+ "401": { description: "Unauthorized" },
211
+ "403": { description: "Forbidden" },
212
+ },
213
+ },
214
+ };
215
+ }
216
+ }
217
+ catch (error) {
218
+ logger.error(`${this.instanceNameWithSuffix} - Error publishing message to topic ${topic}${attribute}: ${error.message}`);
219
+ }
220
+ }
221
+ post(..._args) {
222
+ // Implement POST logic or route binding here
223
+ return "POST called";
224
+ }
225
+ extractBearerToken(req, res) {
226
+ const authHeader = req.headers["authorization"];
227
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
228
+ res.status(401).json({ error: "Missing or invalid Authorization header" });
229
+ return undefined;
230
+ }
231
+ return authHeader.slice(7);
232
+ }
233
+ async getPublicKeyFromJwks(token) {
234
+ // Decode header to get kid
235
+ const decoded = jwt.decode(token, { complete: true });
236
+ const kid = decoded?.header?.kid;
237
+ const keys = await this.fetchJwksKeys();
238
+ let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
239
+ // If no kid match and activeKidUrl configured, try that
240
+ if (!jwk && this.options?.jwks?.activeKidUrl) {
241
+ try {
242
+ const resp = await fetch(this.options.jwks.activeKidUrl);
243
+ if (resp.ok) {
244
+ const activeKid = await resp.text();
245
+ jwk = keys.find((k) => k.kid === activeKid.trim());
246
+ }
247
+ }
248
+ catch (_) {
249
+ // ignore and fall through
250
+ }
251
+ }
252
+ // If still not found but only one key, use it
253
+ if (!jwk && keys.length === 1) {
254
+ jwk = keys[0];
255
+ }
256
+ if (!jwk) {
257
+ throw new Error("Signing key not found in JWKS");
258
+ }
259
+ // Prefer x5c certificate if provided
260
+ if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
261
+ return this.certFromX5c(jwk.x5c[0]);
262
+ }
263
+ // Build PEM from JWK (RSA)
264
+ if (jwk.kty === "RSA" && jwk.n && jwk.e) {
265
+ const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
266
+ return keyObj.export({ type: "spki", format: "pem" }).toString();
267
+ }
268
+ throw new Error("Unsupported JWK format");
269
+ }
270
+ async fetchJwksKeys() {
271
+ const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
272
+ const now = Date.now();
273
+ if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
274
+ return this.jwksCache.keys;
275
+ }
276
+ const url = this.options.jwks.wellKnownJwksUrl;
277
+ const resp = await fetch(url);
278
+ if (!resp.ok) {
279
+ throw new Error(`Failed to fetch JWKS (${resp.status})`);
280
+ }
281
+ const body = await resp.json();
282
+ const keys = Array.isArray(body?.keys) ? body.keys : [];
283
+ this.jwksCache = { keys, fetchedAt: now };
284
+ return keys;
285
+ }
286
+ certFromX5c(x5cFirst) {
287
+ const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
288
+ return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
289
+ }
290
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@uns-kit/api",
3
+ "version": "0.0.1",
4
+ "description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Aljoša Vister <aljosa.vister@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/uns-datahub/uns-kit.git",
11
+ "directory": "packages/uns-api"
12
+ },
13
+ "keywords": [
14
+ "uns",
15
+ "api",
16
+ "express",
17
+ "http",
18
+ "mqtt",
19
+ "typescript"
20
+ ],
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "exports": {
25
+ "./*": "./dist/*"
26
+ },
27
+ "main": "dist/index.js",
28
+ "types": "dist/index.d.ts",
29
+ "dependencies": {
30
+ "@uns-kit/core": "^0.0.1",
31
+ "jsonwebtoken": "^9.0.2",
32
+ "cookie-parser": "^1.4.7",
33
+ "express": "^5.1.0",
34
+ "multer": "^2.0.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/jsonwebtoken": "^9.0.10",
38
+ "@types/cookie-parser": "^1.4.9",
39
+ "@types/express": "^5.0.3",
40
+ "@types/multer": "^2.0.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit"
45
+ }
46
+ }