@uns-kit/api 3.0.41 → 3.0.42

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/README.md CHANGED
@@ -14,6 +14,14 @@ For the MQTT topic registry published by the API plugin, see `../../docs/uns-top
14
14
  | [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
15
15
  | [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
16
16
 
17
+ ## Declared operator operations (local source candidate)
18
+
19
+ See the shared [operator API contract and example](../../docs/operator-api.md).
20
+ An application explicitly declares its read/command operations; the SDK enforces
21
+ current authority and exact launch/body binding. Application business checks and
22
+ durable command receipts remain application-owned. Legacy endpoints gain no
23
+ operator grant automatically.
24
+
17
25
  ## Installation
18
26
 
19
27
  ```bash
package/dist/app.js CHANGED
@@ -9,6 +9,7 @@ import { basePath } from "@uns-kit/core/base-path.js";
9
9
  import logger from "@uns-kit/core/logger.js";
10
10
  import os from 'os';
11
11
  import { logRequestContext } from "./request-log.js";
12
+ import { OPERATOR_OUTCOME_HEADER } from "@uns-kit/core/runtime/operator-api.js";
12
13
  const normalizeBasePrefix = (value) => {
13
14
  if (!value)
14
15
  return "";
@@ -93,6 +94,7 @@ export default class App {
93
94
  return next();
94
95
  if (!this.ownershipGate()) {
95
96
  res.setHeader("Retry-After", "1");
97
+ res.setHeader(OPERATOR_OUTCOME_HEADER, "not-applied");
96
98
  res.status(503).json({ error: "RUNTIME_PASSIVE", message: "Runtime ownership is being transferred. Retry the request." });
97
99
  return;
98
100
  }
@@ -0,0 +1,26 @@
1
+ import { type OperatorDeployment, type OperatorOperation, type OperatorOperationDefinition } from "@uns-kit/core/runtime/operator-api.js";
2
+ /** Use only when the application can prove its command had no external effect.
3
+ * A database/transport failure after a possible commit must stay uncertain. */
4
+ export declare function markOperatorNotApplied(response: {
5
+ setHeader(name: string, value: string): unknown;
6
+ }): void;
7
+ export declare function operatorEndpointDeclarations(definitions: OperatorOperationDefinition[] | undefined, method: string, path: string, authenticationConfigured: boolean): OperatorOperation[];
8
+ /** Route callbacks pass claims only after the existing JWT/JWKS/path checks.
9
+ * Delegated authority is bound again here, inside the application process. */
10
+ export declare class OperatorApiAuthority {
11
+ private readonly options;
12
+ private pending;
13
+ constructor(options: {
14
+ deployment: () => OperatorDeployment;
15
+ statusUrl?: string;
16
+ fetch?: typeof fetch;
17
+ });
18
+ authorize(input: {
19
+ declarations: OperatorOperation[];
20
+ claims: Record<string, unknown>;
21
+ authorization: string;
22
+ payload: unknown;
23
+ query?: Record<string, unknown>;
24
+ }): Promise<OperatorOperation | undefined>;
25
+ private currentAuthorization;
26
+ }
@@ -0,0 +1,128 @@
1
+ import { OPERATOR_DELEGATION_CLAIM, OPERATOR_OUTCOME_HEADER, OperatorApiError, assertOperatorDelegation, assertOperatorPayload, operatorDeploymentSchema, operatorOperation, } from "@uns-kit/core/runtime/operator-api.js";
2
+ /** Use only when the application can prove its command had no external effect.
3
+ * A database/transport failure after a possible commit must stay uncertain. */
4
+ export function markOperatorNotApplied(response) {
5
+ response.setHeader(OPERATOR_OUTCOME_HEADER, "not-applied");
6
+ }
7
+ export function operatorEndpointDeclarations(definitions, method, path, authenticationConfigured) {
8
+ if (!definitions?.length)
9
+ return [];
10
+ if (!authenticationConfigured)
11
+ throw new OperatorApiError("operator_endpoint_requires_authentication", 503);
12
+ if (definitions.length > 16)
13
+ throw new OperatorApiError("operator_endpoint_declaration_limit");
14
+ const parsed = definitions.map((d) => operatorOperation(d, method, path));
15
+ if (new Set(parsed.map((d) => d.id)).size !== parsed.length)
16
+ throw new OperatorApiError("operator_endpoint_duplicate_id");
17
+ return parsed;
18
+ }
19
+ /** Route callbacks pass claims only after the existing JWT/JWKS/path checks.
20
+ * Delegated authority is bound again here, inside the application process. */
21
+ export class OperatorApiAuthority {
22
+ options;
23
+ pending = 0;
24
+ constructor(options) {
25
+ this.options = options;
26
+ }
27
+ async authorize(input) {
28
+ const delegated = input.claims[OPERATOR_DELEGATION_CLAIM] !== undefined;
29
+ if (!input.declarations.length) {
30
+ if (delegated)
31
+ throw new OperatorApiError("operator_endpoint_not_declared", 403);
32
+ return undefined;
33
+ }
34
+ const roles = [input.claims["role"], ...(Array.isArray(input.claims["roles"]) ? input.claims["roles"] : [])];
35
+ if (!roles.some((r) => r === "admin" || r === "operator") || typeof input.claims["sub"] !== "string" || !input.claims["sub"])
36
+ throw new OperatorApiError("operator_role_required", 403);
37
+ if (input.query && Object.keys(input.query).length)
38
+ throw new OperatorApiError("operator_query_not_supported");
39
+ const matched = input.declarations.filter((d) => {
40
+ try {
41
+ assertOperatorPayload(d, input.payload);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ });
48
+ if (matched.length !== 1)
49
+ throw new OperatorApiError(matched.length ? "operator_request_ambiguous" : "operator_request_invalid");
50
+ const operation = matched[0];
51
+ if (delegated) {
52
+ let deployment;
53
+ try {
54
+ deployment = operatorDeploymentSchema.parse(this.options.deployment());
55
+ }
56
+ catch {
57
+ throw new OperatorApiError("operator_deployment_unavailable", 503);
58
+ }
59
+ try {
60
+ assertOperatorDelegation(input.claims, operation, input.payload, deployment);
61
+ }
62
+ catch {
63
+ throw new OperatorApiError("operator_delegation_rejected", 403);
64
+ }
65
+ if (!this.options.statusUrl)
66
+ throw new OperatorApiError("operator_authorization_status_required", 503);
67
+ }
68
+ // A standalone, locally verified native operator token can remain offline.
69
+ // Delegated machine authority always requires a fresh parent-status check.
70
+ if (this.options.statusUrl)
71
+ await this.currentAuthorization(input.authorization);
72
+ return operation;
73
+ }
74
+ async currentAuthorization(authorization) {
75
+ if (this.pending >= 4)
76
+ throw new OperatorApiError("operator_authorization_capacity", 503);
77
+ this.pending++;
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), 1000);
80
+ try {
81
+ const response = await (this.options.fetch ?? fetch)(this.options.statusUrl, {
82
+ method: "POST",
83
+ headers: { authorization },
84
+ signal: controller.signal,
85
+ redirect: "error",
86
+ });
87
+ if (response.status === 401 || response.status === 403)
88
+ throw new OperatorApiError("operator_authorization_inactive", 403);
89
+ if (!response.ok || Number(response.headers.get("content-length") ?? 0) > 4096)
90
+ throw new OperatorApiError("operator_authorization_unavailable", 503);
91
+ if (!response.body)
92
+ throw new OperatorApiError("operator_authorization_unavailable", 503);
93
+ const reader = response.body.getReader();
94
+ let length = 0, body = "";
95
+ try {
96
+ const decoder = new TextDecoder();
97
+ while (true) {
98
+ const part = await reader.read();
99
+ if (part.done)
100
+ break;
101
+ length += part.value.byteLength;
102
+ if (length > 4096) {
103
+ await reader.cancel();
104
+ throw new OperatorApiError("operator_authorization_unavailable", 503);
105
+ }
106
+ body += decoder.decode(part.value, { stream: true });
107
+ }
108
+ body += decoder.decode();
109
+ }
110
+ finally {
111
+ reader.releaseLock();
112
+ }
113
+ const status = JSON.parse(body);
114
+ if (status.active !== true || status.protocolVersion !== 1)
115
+ throw new OperatorApiError("operator_authorization_inactive", 403);
116
+ }
117
+ catch (error) {
118
+ if (error instanceof OperatorApiError)
119
+ throw error;
120
+ throw new OperatorApiError("operator_authorization_unavailable", 503);
121
+ }
122
+ finally {
123
+ controller.abort();
124
+ clearTimeout(timer);
125
+ this.pending--;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,12 @@
1
+ /** Purpose-specific JWKS uses existing approved service public keys. PS256/type/
2
+ * audience keep these tokens out of the normal RS256 API/GraphQL credential path. */
3
+ export declare class OperatorJwtVerifier {
4
+ private readonly url?;
5
+ private readonly fetcher;
6
+ private keys;
7
+ private expires;
8
+ private pending;
9
+ constructor(url?: string, fetcher?: typeof fetch);
10
+ verify(token: string): Promise<Record<string, unknown>>;
11
+ private load;
12
+ }
@@ -0,0 +1,89 @@
1
+ import { OPERATOR_JWT_ALGORITHM, OPERATOR_JWT_AUDIENCE, OPERATOR_JWT_TYPE, OperatorApiError } from "@uns-kit/core/runtime/operator-api.js";
2
+ import jwt from "jsonwebtoken";
3
+ import { createPublicKey } from "node:crypto";
4
+ /** Purpose-specific JWKS uses existing approved service public keys. PS256/type/
5
+ * audience keep these tokens out of the normal RS256 API/GraphQL credential path. */
6
+ export class OperatorJwtVerifier {
7
+ url;
8
+ fetcher;
9
+ keys = [];
10
+ expires = 0;
11
+ pending;
12
+ constructor(url, fetcher = fetch) {
13
+ this.url = url;
14
+ this.fetcher = fetcher;
15
+ }
16
+ async verify(token) {
17
+ const decoded = jwt.decode(token, { complete: true });
18
+ if (!decoded ||
19
+ decoded.header.alg !== OPERATOR_JWT_ALGORITHM ||
20
+ decoded.header.typ !== OPERATOR_JWT_TYPE ||
21
+ typeof decoded.header.kid !== "string" ||
22
+ decoded.header.kid.length > 256 ||
23
+ !this.url)
24
+ throw new OperatorApiError("operator_jwt_invalid", 401);
25
+ if (Date.now() >= this.expires || !this.keys.some((k) => k["kid"] === decoded.header.kid))
26
+ await this.load();
27
+ const keys = this.keys.filter((k) => k["kid"] === decoded.header.kid && k["kty"] === "RSA" && k["alg"] === OPERATOR_JWT_ALGORITHM && !k["d"]);
28
+ if (keys.length !== 1)
29
+ throw new OperatorApiError("operator_jwt_key_unavailable", 401);
30
+ try {
31
+ const key = createPublicKey({ key: keys[0], format: "jwk" });
32
+ const claims = jwt.verify(token, key, { algorithms: [OPERATOR_JWT_ALGORITHM], audience: OPERATOR_JWT_AUDIENCE, maxAge: "30s" });
33
+ if (!claims || typeof claims !== "object")
34
+ throw new Error("invalid");
35
+ return claims;
36
+ }
37
+ catch {
38
+ throw new OperatorApiError("operator_jwt_invalid", 401);
39
+ }
40
+ }
41
+ async load() {
42
+ this.pending ??= (async () => {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), 1000);
45
+ try {
46
+ const response = await this.fetcher(this.url, { redirect: "error", signal: controller.signal });
47
+ if (!response.ok || !response.body || Number(response.headers.get("content-length") ?? 0) > 131072)
48
+ throw new Error("unavailable");
49
+ const reader = response.body.getReader();
50
+ const decoder = new TextDecoder();
51
+ let size = 0, body = "";
52
+ try {
53
+ while (true) {
54
+ const part = await reader.read();
55
+ if (part.done)
56
+ break;
57
+ size += part.value.byteLength;
58
+ if (size > 131072) {
59
+ await reader.cancel();
60
+ throw new Error("oversize");
61
+ }
62
+ body += decoder.decode(part.value, { stream: true });
63
+ }
64
+ body += decoder.decode();
65
+ }
66
+ finally {
67
+ reader.releaseLock();
68
+ }
69
+ const document = JSON.parse(body);
70
+ if (!Array.isArray(document.keys) || document.keys.length > 64)
71
+ throw new Error("invalid");
72
+ this.keys = document.keys;
73
+ this.expires = Date.now() + 15000;
74
+ }
75
+ catch {
76
+ this.keys = [];
77
+ this.expires = 0;
78
+ throw new OperatorApiError("operator_jwks_unavailable", 503);
79
+ }
80
+ finally {
81
+ controller.abort();
82
+ clearTimeout(timer);
83
+ }
84
+ })().finally(() => {
85
+ this.pending = undefined;
86
+ });
87
+ return this.pending;
88
+ }
89
+ }
@@ -0,0 +1,6 @@
1
+ import type { IPostEndpointOptions } from "@uns-kit/core/uns/uns-interfaces.js";
2
+ /** Service APIs and OpenAPI must agree when a caller overrides requestBody on
3
+ * api.post/put/patch/delete after buildServiceApiInteractions(). A body editor
4
+ * needs the body descriptor even when no flattened catalog schema was supplied.
5
+ * The full JSON Schema stays authoritative in the existing OpenAPI document. */
6
+ export declare function withServiceApiRequestBody(serviceApi: Record<string, unknown> | null | undefined, requestBody: IPostEndpointOptions["requestBody"]): Record<string, unknown> | null;
@@ -0,0 +1,20 @@
1
+ /** Service APIs and OpenAPI must agree when a caller overrides requestBody on
2
+ * api.post/put/patch/delete after buildServiceApiInteractions(). A body editor
3
+ * needs the body descriptor even when no flattened catalog schema was supplied.
4
+ * The full JSON Schema stays authoritative in the existing OpenAPI document. */
5
+ export function withServiceApiRequestBody(serviceApi, requestBody) {
6
+ if (!serviceApi || !requestBody)
7
+ return serviceApi ?? null;
8
+ const raw = serviceApi["requestBody"];
9
+ const previous = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
10
+ return {
11
+ ...serviceApi,
12
+ requestBody: {
13
+ ...previous,
14
+ required: requestBody.required ?? true,
15
+ description: requestBody.description ?? previous["description"] ?? null,
16
+ contentType: "application/json",
17
+ schemas: Array.isArray(previous["schemas"]) ? previous["schemas"] : [],
18
+ },
19
+ };
20
+ }
@@ -21,12 +21,15 @@ export default class UnsApiProxy extends UnsProxy {
21
21
  private statusInterval;
22
22
  private readonly statusIntervalMs;
23
23
  private readonly dataCatalogOffers;
24
+ private readonly operatorAuthority;
25
+ private readonly operatorJwt;
24
26
  constructor(processName: string, instanceName: string, options: IApiProxyOptions);
25
27
  setOwnershipGate(gate: () => boolean): void;
26
28
  drainRequests(): Promise<void>;
27
29
  /** Track an explicitly read-only handler through completion, including client disconnects. */
28
30
  runReadOnlyRequest<T>(res: Response, operation: () => T | Promise<T>): Promise<T>;
29
31
  getProcessName(): string;
32
+ private operatorAuthorized;
30
33
  private getPublishedApiHost;
31
34
  /**
32
35
  * Unregister endpoint
@@ -69,6 +72,7 @@ export default class UnsApiProxy extends UnsProxy {
69
72
  private emitStatusMetrics;
70
73
  private registerHealthEndpoint;
71
74
  private getApiEventName;
75
+ private verifyRequestToken;
72
76
  private extractBearerToken;
73
77
  private getPublicKeyFromJwks;
74
78
  private fetchJwksKeys;
@@ -1,3 +1,4 @@
1
+ import { withServiceApiRequestBody } from "./service-api-request-body.js";
1
2
  import { readFileSync } from "fs";
2
3
  import jwt from "jsonwebtoken";
3
4
  import * as path from "path";
@@ -12,6 +13,9 @@ import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher.js";
12
13
  import { DataSizeMeasurements, PhysicalMeasurements } from "@uns-kit/core/uns/uns-measurements.js";
13
14
  import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
14
15
  import App from "./app.js";
16
+ import { OperatorApiAuthority, operatorEndpointDeclarations, markOperatorNotApplied } from "./operator-api-authority.js";
17
+ import { OPERATOR_API_EXTENSION, OPERATOR_AUTH_SCHEME, OPERATOR_DELEGATION_CLAIM, OperatorApiError } from "@uns-kit/core/runtime/operator-api.js";
18
+ import { OperatorJwtVerifier } from "./operator-jwt.js";
15
19
  const packageJsonPath = path.join(basePath, "package.json");
16
20
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
17
21
  const normalizeBasePrefix = (value) => {
@@ -61,9 +65,18 @@ export default class UnsApiProxy extends UnsProxy {
61
65
  statusInterval = null;
62
66
  statusIntervalMs = 10_000;
63
67
  dataCatalogOffers = new Map();
68
+ operatorAuthority;
69
+ operatorJwt;
64
70
  constructor(processName, instanceName, options) {
65
71
  super();
66
72
  this.options = options;
73
+ const controllerBase = process.env.UNS_CONTROLLER_PUBLIC_BASE;
74
+ const statusUrl = options.operatorAuthorizationStatusUrl ?? (controllerBase ? new URL("/api/auth/token-status", controllerBase).href : undefined);
75
+ this.operatorJwt = new OperatorJwtVerifier(options.operatorJwksUrl ?? (controllerBase ? new URL("/api/auth/operator-jwks.json", controllerBase).href : undefined));
76
+ this.operatorAuthority = new OperatorApiAuthority({ ...(statusUrl ? { statusUrl } : {}), deployment: () => ({
77
+ controllerName: process.env.UNS_ROUTE_CONTROLLER_NAME, rttNode: process.env.RTT_NODE,
78
+ version: process.env.version ?? packageJson.version, instanceId: process.env.RTT_INSTANCE_ID, launchId: process.env.UNS_ROUTE_LAUNCH_ID,
79
+ }) });
67
80
  this.apiBasePrefix =
68
81
  normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
69
82
  const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
@@ -101,6 +114,25 @@ export default class UnsApiProxy extends UnsProxy {
101
114
  getProcessName() {
102
115
  return this.processName;
103
116
  }
117
+ async operatorAuthorized(req, res, decoded, declarations) {
118
+ try {
119
+ const delegatedScheme = String(req.headers.authorization ?? "").startsWith(OPERATOR_AUTH_SCHEME + " ");
120
+ if (delegatedScheme !== (decoded[OPERATOR_DELEGATION_CLAIM] !== undefined))
121
+ throw new OperatorApiError("operator_jwt_purpose_mismatch", 403);
122
+ const normalizedAuthorization = delegatedScheme ? "Bearer " + req.headers.authorization.slice(OPERATOR_AUTH_SCHEME.length + 1) : req.headers.authorization;
123
+ await this.operatorAuthority.authorize({ claims: decoded, declarations, payload: req.method === "GET" ? {} : req.body,
124
+ authorization: normalizedAuthorization, query: req.query });
125
+ // Existing application handlers inspect an already-verified bearer.
126
+ req.headers.authorization = normalizedAuthorization;
127
+ return true;
128
+ }
129
+ catch (error) {
130
+ const status = error instanceof OperatorApiError ? error.status : 503;
131
+ markOperatorNotApplied(res);
132
+ res.status(status).json({ error: error instanceof OperatorApiError ? error.code : "operator_authorization_unavailable" });
133
+ return false;
134
+ }
135
+ }
104
136
  getPublishedApiHost() {
105
137
  const addressInfo = this.app.server.address();
106
138
  const port = addressInfo && typeof addressInfo === "object" ? addressInfo.port : undefined;
@@ -166,6 +198,7 @@ export default class UnsApiProxy extends UnsProxy {
166
198
  const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
167
199
  const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
168
200
  const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
201
+ const operatorDeclarations = operatorEndpointDeclarations(options?.operatorOperations, "GET", fullPath, Boolean(this.options.jwks?.wellKnownJwksUrl || this.options.jwtSecret));
169
202
  try {
170
203
  this.registerApiEndpoint({
171
204
  timestamp: time,
@@ -222,9 +255,7 @@ export default class UnsApiProxy extends UnsProxy {
222
255
  const token = this.extractBearerToken(req, res);
223
256
  if (!token)
224
257
  return; // response already sent
225
- const publicKey = await this.getPublicKeyFromJwks(token);
226
- const algorithms = this.options.jwks.algorithms || ["RS256"];
227
- const decoded = jwt.verify(token, publicKey, { algorithms });
258
+ const decoded = await this.verifyRequestToken(req, token);
228
259
  const accessRules = Array.isArray(decoded?.accessRules)
229
260
  ? decoded.accessRules
230
261
  : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
@@ -236,22 +267,26 @@ export default class UnsApiProxy extends UnsProxy {
236
267
  if (!allowed) {
237
268
  return res.status(403).json({ error: "Path not allowed by token access rules" });
238
269
  }
270
+ if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
271
+ return;
239
272
  handler(req, res);
240
273
  }
241
274
  catch (err) {
275
+ if (err instanceof OperatorApiError) {
276
+ markOperatorNotApplied(res);
277
+ return res.status(err.status).json({ error: err.code });
278
+ }
242
279
  return res.status(401).json({ error: "Invalid token" });
243
280
  }
244
281
  });
245
282
  }
246
283
  else if (this.options?.jwtSecret) {
247
- this.app.router.get(fullPath, (req, res) => {
248
- const authHeader = req.headers["authorization"];
249
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
250
- return res.status(401).json({ error: "Missing or invalid Authorization header" });
251
- }
252
- const token = authHeader.slice(7);
284
+ this.app.router.get(fullPath, async (req, res) => {
285
+ const token = this.extractBearerToken(req, res);
286
+ if (!token)
287
+ return;
253
288
  try {
254
- const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
289
+ const decoded = await this.verifyRequestToken(req, token);
255
290
  const accessRules = Array.isArray(decoded?.accessRules)
256
291
  ? decoded.accessRules
257
292
  : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
@@ -263,9 +298,15 @@ export default class UnsApiProxy extends UnsProxy {
263
298
  if (!allowed) {
264
299
  return res.status(403).json({ error: "Path not allowed by token access rules" });
265
300
  }
301
+ if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
302
+ return;
266
303
  handler(req, res);
267
304
  }
268
305
  catch (err) {
306
+ if (err instanceof OperatorApiError) {
307
+ markOperatorNotApplied(res);
308
+ return res.status(err.status).json({ error: err.code });
309
+ }
269
310
  return res.status(401).json({ error: "Invalid token" });
270
311
  }
271
312
  });
@@ -325,6 +366,9 @@ export default class UnsApiProxy extends UnsProxy {
325
366
  },
326
367
  };
327
368
  }
369
+ if (operatorDeclarations.length && this.app.swaggerSpec?.paths?.[apiPath]?.get) {
370
+ this.app.swaggerSpec.paths[apiPath].get[OPERATOR_API_EXTENSION] = operatorDeclarations;
371
+ }
328
372
  }
329
373
  catch (error) {
330
374
  logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
@@ -430,6 +474,7 @@ export default class UnsApiProxy extends UnsProxy {
430
474
  const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
431
475
  const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
432
476
  const methodKey = method.toLowerCase();
477
+ const operatorDeclarations = operatorEndpointDeclarations(options?.operatorOperations, method, fullPath, Boolean(this.options.jwks?.wellKnownJwksUrl || this.options.jwtSecret));
433
478
  try {
434
479
  this.registerApiEndpoint({
435
480
  timestamp: time,
@@ -443,7 +488,7 @@ export default class UnsApiProxy extends UnsProxy {
443
488
  apiMethod: method,
444
489
  apiQueryParams: [],
445
490
  apiDescription: options?.apiDescription,
446
- serviceApi: options?.serviceApi ?? null,
491
+ serviceApi: withServiceApiRequestBody(options?.serviceApi, options?.requestBody),
447
492
  attributeType: UnsAttributeType.Api,
448
493
  apiSwaggerEndpoint: swaggerPath,
449
494
  asset,
@@ -460,9 +505,7 @@ export default class UnsApiProxy extends UnsProxy {
460
505
  const token = this.extractBearerToken(req, res);
461
506
  if (!token)
462
507
  return;
463
- const publicKey = await this.getPublicKeyFromJwks(token);
464
- const algorithms = this.options.jwks.algorithms || ["RS256"];
465
- const decoded = jwt.verify(token, publicKey, { algorithms });
508
+ const decoded = await this.verifyRequestToken(req, token);
466
509
  const accessRules = Array.isArray(decoded?.accessRules)
467
510
  ? decoded.accessRules
468
511
  : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
@@ -474,22 +517,26 @@ export default class UnsApiProxy extends UnsProxy {
474
517
  if (!allowed) {
475
518
  return res.status(403).json({ error: "Path not allowed by token access rules" });
476
519
  }
520
+ if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
521
+ return;
477
522
  handler(req, res);
478
523
  }
479
524
  catch (_err) {
525
+ if (_err instanceof OperatorApiError) {
526
+ markOperatorNotApplied(res);
527
+ return res.status(_err.status).json({ error: _err.code });
528
+ }
480
529
  return res.status(401).json({ error: "Invalid token" });
481
530
  }
482
531
  });
483
532
  }
484
533
  else if (this.options?.jwtSecret) {
485
- routerMethod(fullPath, (req, res) => {
486
- const authHeader = req.headers["authorization"];
487
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
488
- return res.status(401).json({ error: "Missing or invalid Authorization header" });
489
- }
490
- const token = authHeader.slice(7);
534
+ routerMethod(fullPath, async (req, res) => {
535
+ const token = this.extractBearerToken(req, res);
536
+ if (!token)
537
+ return;
491
538
  try {
492
- const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
539
+ const decoded = await this.verifyRequestToken(req, token);
493
540
  const accessRules = Array.isArray(decoded?.accessRules)
494
541
  ? decoded.accessRules
495
542
  : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
@@ -501,9 +548,15 @@ export default class UnsApiProxy extends UnsProxy {
501
548
  if (!allowed) {
502
549
  return res.status(403).json({ error: "Path not allowed by token access rules" });
503
550
  }
551
+ if (!await this.operatorAuthorized(req, res, decoded, operatorDeclarations))
552
+ return;
504
553
  handler(req, res);
505
554
  }
506
555
  catch (_err) {
556
+ if (_err instanceof OperatorApiError) {
557
+ markOperatorNotApplied(res);
558
+ return res.status(_err.status).json({ error: _err.code });
559
+ }
507
560
  return res.status(401).json({ error: "Invalid token" });
508
561
  }
509
562
  });
@@ -539,6 +592,9 @@ export default class UnsApiProxy extends UnsProxy {
539
592
  },
540
593
  };
541
594
  }
595
+ if (operatorDeclarations.length && this.app.swaggerSpec?.paths?.[apiPath]?.[methodKey]) {
596
+ this.app.swaggerSpec.paths[apiPath][methodKey][OPERATOR_API_EXTENSION] = operatorDeclarations;
597
+ }
542
598
  }
543
599
  catch (error) {
544
600
  logger.error(`${this.instanceNameWithSuffix} - Error registering ${method} route ${fullPath}: ${error.message}`);
@@ -664,13 +720,22 @@ export default class UnsApiProxy extends UnsProxy {
664
720
  return "apiDeleteEvent";
665
721
  }
666
722
  }
723
+ async verifyRequestToken(req, token) {
724
+ if (String(req.headers.authorization ?? "").startsWith(OPERATOR_AUTH_SCHEME + " "))
725
+ return this.operatorJwt.verify(token);
726
+ if (this.options.jwks?.wellKnownJwksUrl) {
727
+ const key = await this.getPublicKeyFromJwks(token);
728
+ return jwt.verify(token, key, { algorithms: this.options.jwks.algorithms || ["RS256"] });
729
+ }
730
+ return jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
731
+ }
667
732
  extractBearerToken(req, res) {
668
733
  const authHeader = req.headers["authorization"];
669
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
734
+ if (typeof authHeader !== "string" || !(authHeader.startsWith("Bearer ") || authHeader.startsWith(OPERATOR_AUTH_SCHEME + " "))) {
670
735
  res.status(401).json({ error: "Missing or invalid Authorization header" });
671
736
  return undefined;
672
737
  }
673
- return authHeader.slice(7);
738
+ return authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader.slice(OPERATOR_AUTH_SCHEME.length + 1);
674
739
  }
675
740
  async getPublicKeyFromJwks(token) {
676
741
  // Decode header to get kid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/api",
3
- "version": "3.0.41",
3
+ "version": "3.0.42",
4
4
  "description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "multer": "^2.2.0",
38
38
  "hyparquet-writer": "^0.16.5",
39
39
  "uuid": "^14.0.1",
40
- "@uns-kit/core": "3.0.41"
40
+ "@uns-kit/core": "3.0.42"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/cookie-parser": "^1.4.10",