c8y-nitro 0.6.0 → 0.7.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/dist/{bootstrap-DUDpmXcU.mjs → bootstrap-ObaEF7rN.mjs} +6 -6
- package/dist/{c8y-api-BbRS1-Ls.mjs → c8y-api-BFjK6AYb.mjs} +36 -8
- package/dist/cli/index.mjs +4 -4
- package/dist/client-DxmbyZju.mjs +236 -0
- package/dist/{credentials-9FO7rTIR.d.mts → credentials-eKnJ7m9Y.d.mts} +0 -1
- package/dist/{index-uOwpI6rD.d.mts → index-vu4xaFkz.d.mts} +70 -4
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +187 -45
- package/dist/logging-BUYT8Z2V.mjs +57 -0
- package/dist/{options-Web5UyIU.mjs → options-B7_yb_wr.mjs} +4 -4
- package/dist/{package-DsLC9Mo3.mjs → package-D5xRshsx.mjs} +1 -1
- package/dist/{roles-D7rpbxPp.mjs → roles-B58Fguop.mjs} +4 -4
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +403 -5
- package/dist/runtime/handlers/openapi.mjs +56 -0
- package/dist/runtime/middlewares/c8y-client.mjs +2 -2
- package/dist/runtime/middlewares/dev-user.dev.mjs +2 -2
- package/dist/runtime/plugins/c8y-variables.dev.mjs +1 -1
- package/dist/types.d.mts +3 -3
- package/dist/utils.d.mts +280 -42
- package/dist/utils.mjs +470 -110
- package/package.json +11 -9
- package/dist/logging-CVlaRS4O.mjs +0 -3
- /package/dist/{config-BRnvtthI.mjs → config-BVQHqlgV.mjs} +0 -0
- /package/dist/{env-file-B0BK-uZW.mjs → env-file-BrOTmVjA.mjs} +0 -0
package/dist/utils.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import process from "node:process";
|
|
3
|
-
import { useLogger } from "evlog/nitro/v3";
|
|
4
|
+
import { useLogger as useLogger$1 } from "evlog/nitro/v3";
|
|
4
5
|
import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
|
|
5
6
|
import { defineCachedFunction } from "nitro/cache";
|
|
6
|
-
import {
|
|
7
|
-
import { HTTPError, defineHandler } from "nitro/h3";
|
|
7
|
+
import { createError as createError$1, createLogger } from "evlog";
|
|
8
8
|
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
9
9
|
import { useNitroHooks } from "nitro/app";
|
|
10
|
+
import { defineHandler } from "nitro/h3";
|
|
10
11
|
import { c8yManifest } from "c8y-nitro/runtime";
|
|
11
|
-
import { createError, createLogger } from "evlog";
|
|
12
12
|
import { ms } from "itty-time";
|
|
13
13
|
import { tasks } from "#nitro/virtual/tasks";
|
|
14
14
|
//#region src/utils/internal/common.ts
|
|
@@ -83,6 +83,95 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
|
|
|
83
83
|
getKey: (requestOrEvent) => createCurrentUserTenantCacheKey(requestOrEvent),
|
|
84
84
|
shouldBypassCache: (requestOrEvent) => !getCurrentUserTenantCacheKeyMaterial(requestOrEvent)
|
|
85
85
|
});
|
|
86
|
+
defineCachedFunction(async (requestOrEvent, _tenantId) => {
|
|
87
|
+
return (await useUserClient(requestOrEvent).tenant.current()).data.domainName;
|
|
88
|
+
}, {
|
|
89
|
+
maxAge: 86400,
|
|
90
|
+
name: "_c8y_nitro_get_user_tenant_domain",
|
|
91
|
+
group: "c8y_nitro",
|
|
92
|
+
swr: false,
|
|
93
|
+
getKey: (_requestOrEvent, tenantId) => tenantId
|
|
94
|
+
});
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/utils/logging.ts
|
|
97
|
+
/**
|
|
98
|
+
* Returns the request-scoped wide-event logger for the current request.
|
|
99
|
+
*
|
|
100
|
+
* Accepts **either** an `H3Event` (as received by a route handler) **or** the
|
|
101
|
+
* `ServerRequest` returned by Nitro's `useRequest()` — both carry the same
|
|
102
|
+
* request context, so `useLogger(event)` and `useLogger(useRequest())` are
|
|
103
|
+
* equivalent.
|
|
104
|
+
*
|
|
105
|
+
* evlog's own `useLogger` only accepts an `H3Event` and reads the logger from
|
|
106
|
+
* `event.req.context.log`; `useRequest()` returns that `ServerRequest` directly
|
|
107
|
+
* (no `.req`), which would throw. This wrapper normalizes both shapes to the
|
|
108
|
+
* `{ req }` event evlog expects, restoring the accept-both behavior.
|
|
109
|
+
*
|
|
110
|
+
* @param eventOrRequest - The current `H3Event` or `ServerRequest` (`useRequest()`).
|
|
111
|
+
* @param service - Optional service name to override the default.
|
|
112
|
+
* @example
|
|
113
|
+
* // In a route handler:
|
|
114
|
+
* const log = useLogger(event)
|
|
115
|
+
*
|
|
116
|
+
* // Deeper in the call stack with experimental.asyncContext enabled:
|
|
117
|
+
* import { useRequest } from 'nitro/context'
|
|
118
|
+
* const log = useLogger(useRequest())
|
|
119
|
+
*/
|
|
120
|
+
function useLogger(eventOrRequest, service) {
|
|
121
|
+
return useLogger$1("req" in eventOrRequest ? eventOrRequest : { req: eventOrRequest }, service);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create a structured error for a Cumulocity microservice. Always prefer this
|
|
125
|
+
* over Nitro/h3's built-in `createError`: the returned error is captured in the
|
|
126
|
+
* wide log event **and** serialized into the JSON response under a `data` key.
|
|
127
|
+
*
|
|
128
|
+
* ## What the caller sees vs what stays on the server
|
|
129
|
+
*
|
|
130
|
+
* Every field **except `internal`** is sent to the client in the HTTP response.
|
|
131
|
+
* `internal` is stripped from the response and only ever appears in your logs.
|
|
132
|
+
*
|
|
133
|
+
* | Field | Sent to client? | Purpose |
|
|
134
|
+
* | ---------- | :-------------: | ------------------------------------------------------------- |
|
|
135
|
+
* | `message` | yes | Short, safe summary of what went wrong. |
|
|
136
|
+
* | `status` | yes | HTTP status code (default `500`). |
|
|
137
|
+
* | `code` | yes | Stable machine-readable id (e.g. `'PAYMENT_DECLINED'`). |
|
|
138
|
+
* | `why` | yes | Human-readable cause — safe to expose, no secrets. |
|
|
139
|
+
* | `fix` | yes | What the caller can do about it. |
|
|
140
|
+
* | `link` | yes | Docs URL with more detail. |
|
|
141
|
+
* | `cause` | no (logged) | The original `Error` you caught. |
|
|
142
|
+
* | `internal` | no (logged) | Backend-only diagnostics — never reaches the client. |
|
|
143
|
+
*
|
|
144
|
+
* ## Do NOT leak internal system errors
|
|
145
|
+
*
|
|
146
|
+
* Put anything sensitive or diagnostic (raw Cumulocity core responses, DB
|
|
147
|
+
* errors, tokens, tenant internals) in `internal`, **not** in `message`/`why`.
|
|
148
|
+
* `internal` is logged for debugging but never serialized to the response.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* // User-facing error — every field here is visible to the caller:
|
|
152
|
+
* throw createError({
|
|
153
|
+
* code: 'PAYMENT_DECLINED',
|
|
154
|
+
* message: 'Payment failed',
|
|
155
|
+
* status: 402,
|
|
156
|
+
* why: 'Card declined by issuer',
|
|
157
|
+
* fix: 'Try a different payment method',
|
|
158
|
+
* link: 'https://docs.example.com/payments/declined',
|
|
159
|
+
* })
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* // Wrapping an upstream failure — keep the raw payload server-side only:
|
|
163
|
+
* catch (cause) {
|
|
164
|
+
* throw createError({
|
|
165
|
+
* status: 502,
|
|
166
|
+
* message: 'Upstream request failed', // safe, generic — what the caller sees
|
|
167
|
+
* cause: cause as Error,
|
|
168
|
+
* internal: { upstreamStatus: cause?.res?.status, upstreamBody: cause?.data },
|
|
169
|
+
* })
|
|
170
|
+
* }
|
|
171
|
+
*
|
|
172
|
+
* @see https://www.evlog.dev
|
|
173
|
+
*/
|
|
174
|
+
const createError = createError$1;
|
|
86
175
|
//#endregion
|
|
87
176
|
//#region src/utils/credentials.ts
|
|
88
177
|
let prevCredentials = null;
|
|
@@ -151,7 +240,7 @@ const useSubscribedTenantCredentials = Object.assign(async () => {
|
|
|
151
240
|
* Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
|
|
152
241
|
* Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
|
|
153
242
|
* @returns Credentials for the deployed tenant
|
|
154
|
-
* @throws {
|
|
243
|
+
* @throws {EvlogError} If no credentials found for the deployed tenant
|
|
155
244
|
* @example
|
|
156
245
|
* // Get deployed tenant credentials:
|
|
157
246
|
* const creds = await useDeployedTenantCredentials()
|
|
@@ -167,10 +256,9 @@ const useSubscribedTenantCredentials = Object.assign(async () => {
|
|
|
167
256
|
const useDeployedTenantCredentials = Object.assign(async () => {
|
|
168
257
|
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
169
258
|
const allCredsPromise = await useSubscribedTenantCredentials();
|
|
170
|
-
if (!allCredsPromise[tenant]) throw
|
|
259
|
+
if (!allCredsPromise[tenant]) throw createError({
|
|
171
260
|
message: `No credentials found for tenant deployed tenant '${tenant}'`,
|
|
172
|
-
status: 500
|
|
173
|
-
statusText: "Internal Server Error"
|
|
261
|
+
status: 500
|
|
174
262
|
});
|
|
175
263
|
return allCredsPromise[tenant];
|
|
176
264
|
}, {
|
|
@@ -186,7 +274,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
|
|
|
186
274
|
* Results are cached in the request context for subsequent calls within the same request.
|
|
187
275
|
* @param requestOrEvent - The H3Event or ServerRequest from the current request
|
|
188
276
|
* @returns Credentials for the user's tenant
|
|
189
|
-
* @throws {
|
|
277
|
+
* @throws {EvlogError} If no subscribed tenant credentials found for the user's tenant
|
|
190
278
|
* @example
|
|
191
279
|
* // In a request handler:
|
|
192
280
|
* const userCreds = await useUserTenantCredentials(event)
|
|
@@ -200,10 +288,9 @@ async function useUserTenantCredentials(requestOrEvent) {
|
|
|
200
288
|
if (request.context?.["c8y_user_tenant_credentials"]) return request.context["c8y_user_tenant_credentials"];
|
|
201
289
|
const tenantId = await getCurrentUserTenantId(requestOrEvent);
|
|
202
290
|
const userTenantCreds = (await useSubscribedTenantCredentials())[tenantId];
|
|
203
|
-
if (!userTenantCreds) throw
|
|
291
|
+
if (!userTenantCreds) throw createError({
|
|
204
292
|
message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
|
|
205
|
-
status: 500
|
|
206
|
-
statusText: "Internal Server Error"
|
|
293
|
+
status: 500
|
|
207
294
|
});
|
|
208
295
|
request.context ??= {};
|
|
209
296
|
request.context["c8y_user_tenant_credentials"] = userTenantCreds;
|
|
@@ -244,12 +331,12 @@ async function useUserTenantClient(requestOrEvent) {
|
|
|
244
331
|
if (request.context?.["c8y_user_tenant_client"]) return request.context["c8y_user_tenant_client"];
|
|
245
332
|
const tenantId = await getCurrentUserTenantId(requestOrEvent);
|
|
246
333
|
const creds = await useSubscribedTenantCredentials();
|
|
247
|
-
if (!creds[tenantId]) throw
|
|
334
|
+
if (!creds[tenantId]) throw createError({
|
|
248
335
|
message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
|
|
249
|
-
status: 500
|
|
250
|
-
statusText: "Internal Server Error"
|
|
336
|
+
status: 500
|
|
251
337
|
});
|
|
252
338
|
const tenantClient = new Client(new BasicAuth(creds[tenantId]), process.env.C8Y_BASEURL);
|
|
339
|
+
tenantClient.core.tenant = tenantId;
|
|
253
340
|
request.context ??= {};
|
|
254
341
|
request.context["c8y_user_tenant_client"] = tenantClient;
|
|
255
342
|
return tenantClient;
|
|
@@ -269,7 +356,11 @@ async function useUserTenantClient(requestOrEvent) {
|
|
|
269
356
|
async function useSubscribedTenantClients() {
|
|
270
357
|
const creds = await useSubscribedTenantCredentials();
|
|
271
358
|
const clients = {};
|
|
272
|
-
for (const [tenant, tenantCreds] of Object.entries(creds))
|
|
359
|
+
for (const [tenant, tenantCreds] of Object.entries(creds)) {
|
|
360
|
+
const client = new Client(new BasicAuth(tenantCreds), process.env.C8Y_BASEURL);
|
|
361
|
+
client.core.tenant = tenant;
|
|
362
|
+
clients[tenant] = client;
|
|
363
|
+
}
|
|
273
364
|
return clients;
|
|
274
365
|
}
|
|
275
366
|
/**
|
|
@@ -284,12 +375,13 @@ async function useSubscribedTenantClients() {
|
|
|
284
375
|
async function useDeployedTenantClient() {
|
|
285
376
|
const creds = await useSubscribedTenantCredentials();
|
|
286
377
|
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
287
|
-
if (!creds[tenant]) throw
|
|
378
|
+
if (!creds[tenant]) throw createError({
|
|
288
379
|
message: `No subscribed tenant credentials found for tenant '${tenant}'`,
|
|
289
|
-
status: 500
|
|
290
|
-
statusText: "Internal Server Error"
|
|
380
|
+
status: 500
|
|
291
381
|
});
|
|
292
|
-
|
|
382
|
+
const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
|
|
383
|
+
client.core.tenant = tenant;
|
|
384
|
+
return client;
|
|
293
385
|
}
|
|
294
386
|
//#endregion
|
|
295
387
|
//#region src/utils/resources.ts
|
|
@@ -307,10 +399,9 @@ async function useUser(requestOrEvent) {
|
|
|
307
399
|
const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
|
|
308
400
|
if (request.context?.["c8y_user"]) return request.context["c8y_user"];
|
|
309
401
|
const { res, data: user } = await useUserClient(requestOrEvent).user.currentWithEffectiveRoles();
|
|
310
|
-
if (!res.ok) throw
|
|
402
|
+
if (!res.ok) throw createError({
|
|
311
403
|
message: `Failed to fetch current user`,
|
|
312
|
-
status: res.status
|
|
313
|
-
statusText: res.statusText
|
|
404
|
+
status: res.status
|
|
314
405
|
});
|
|
315
406
|
request.context ??= {};
|
|
316
407
|
request.context["c8y_user"] = user;
|
|
@@ -346,9 +437,8 @@ function hasUserRequiredRole(roleOrRoles) {
|
|
|
346
437
|
if (isProbeRequest(event.url.pathname)) return;
|
|
347
438
|
const requiredRoles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
|
|
348
439
|
const userRoles = await useUserRoles(event);
|
|
349
|
-
if (!requiredRoles.some((role) => userRoles.includes(role))) throw
|
|
440
|
+
if (!requiredRoles.some((role) => userRoles.includes(role))) throw createError({
|
|
350
441
|
status: 403,
|
|
351
|
-
statusText: "Forbidden",
|
|
352
442
|
message: `User does not have required role(s) to access this resource: ${requiredRoles.join(", ")}`
|
|
353
443
|
});
|
|
354
444
|
});
|
|
@@ -358,9 +448,8 @@ function isUserFromAllowedTenant(tenantIdOrIds) {
|
|
|
358
448
|
if (isProbeRequest(event.url.pathname)) return;
|
|
359
449
|
const allowedTenants = Array.isArray(tenantIdOrIds) ? tenantIdOrIds : [tenantIdOrIds];
|
|
360
450
|
const userTenantId = await getCurrentUserTenantId(event);
|
|
361
|
-
if (!allowedTenants.includes(userTenantId)) throw
|
|
451
|
+
if (!allowedTenants.includes(userTenantId)) throw createError({
|
|
362
452
|
status: 403,
|
|
363
|
-
statusText: "Forbidden",
|
|
364
453
|
message: `User's tenant '${userTenantId}' is not allowed to access this resource. Allowed tenants: ${allowedTenants.join(", ")}`
|
|
365
454
|
});
|
|
366
455
|
});
|
|
@@ -385,14 +474,9 @@ function isUserFromDeployedTenant() {
|
|
|
385
474
|
if (isProbeRequest(event.url.pathname)) return;
|
|
386
475
|
const userTenantId = await getCurrentUserTenantId(event);
|
|
387
476
|
const deployedTenantId = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
388
|
-
if (!deployedTenantId) throw new
|
|
389
|
-
|
|
390
|
-
statusText: "Internal Server Error",
|
|
391
|
-
message: "C8Y_BOOTSTRAP_TENANT environment variable is not set"
|
|
392
|
-
});
|
|
393
|
-
if (userTenantId !== deployedTenantId) throw new HTTPError({
|
|
477
|
+
if (!deployedTenantId) throw new Error("C8Y_BOOTSTRAP_TENANT environment variable is not set");
|
|
478
|
+
if (userTenantId !== deployedTenantId) throw createError({
|
|
394
479
|
status: 403,
|
|
395
|
-
statusText: "Forbidden",
|
|
396
480
|
message: `Only users from tenant '${deployedTenantId}' can access this resource.`
|
|
397
481
|
});
|
|
398
482
|
});
|
|
@@ -400,14 +484,211 @@ function isUserFromDeployedTenant() {
|
|
|
400
484
|
//#endregion
|
|
401
485
|
//#region src/utils/internal/tenantOptionFetchers.ts
|
|
402
486
|
/**
|
|
403
|
-
* Internal storage for cached
|
|
487
|
+
* Internal storage for cached option fetchers, keyed by
|
|
488
|
+
* `${tenant}::${category}::${key}`.
|
|
489
|
+
*
|
|
490
|
+
* Including the tenant and category in the key keeps options from different
|
|
491
|
+
* tenants (multi-tenancy) and settings categories from colliding on a shared
|
|
492
|
+
* `key`.
|
|
404
493
|
*/
|
|
405
494
|
const tenantOptionFetchers = {};
|
|
495
|
+
/**
|
|
496
|
+
* Builds the composite store key for a tenant/category/key triple.
|
|
497
|
+
* @param tenant - The tenant id owning the option
|
|
498
|
+
* @param category - The settings category
|
|
499
|
+
* @param key - The tenant option key
|
|
500
|
+
*/
|
|
501
|
+
function fetcherStoreKey(tenant, category, key) {
|
|
502
|
+
return `${tenant}::${category}::${key}`;
|
|
503
|
+
}
|
|
406
504
|
//#endregion
|
|
407
505
|
//#region src/utils/tenantOptions.ts
|
|
408
506
|
/**
|
|
507
|
+
* The microservice's own settings category, resolved once from runtime config
|
|
508
|
+
* (`manifest.settingsCategory` → `contextPath` → `name`). This is independent of
|
|
509
|
+
* which tenant is targeted.
|
|
510
|
+
*/
|
|
511
|
+
function getOwnCategory() {
|
|
512
|
+
return useRuntimeConfig().c8ySettingsCategory;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Resolves the tenant id a client operates on, used for per-tenant cache keys.
|
|
516
|
+
*
|
|
517
|
+
* Prefers the stamped `client.core.tenant` (set by the `use*Client` helpers);
|
|
518
|
+
* otherwise resolves it on demand via `/tenant/currentTenant` and memoizes it
|
|
519
|
+
* back onto the client.
|
|
520
|
+
* @param client - The Cumulocity client
|
|
521
|
+
*/
|
|
522
|
+
async function resolveClientTenant(client) {
|
|
523
|
+
if (client.core.tenant) return client.core.tenant;
|
|
524
|
+
const res = await client.core.fetch("/tenant/currentTenant", {
|
|
525
|
+
method: "GET",
|
|
526
|
+
headers: { Accept: "application/json" }
|
|
527
|
+
});
|
|
528
|
+
const tenant = res.ok ? (await res.json())?.name : void 0;
|
|
529
|
+
if (!tenant) throw createError({
|
|
530
|
+
status: 500,
|
|
531
|
+
message: "Could not determine the target tenant",
|
|
532
|
+
fix: "This is unexpected. Retry the request, and contact the microservice operator if it keeps happening.",
|
|
533
|
+
internal: {
|
|
534
|
+
reason: res.ok ? "GET /tenant/currentTenant returned no tenant name" : "GET /tenant/currentTenant failed",
|
|
535
|
+
upstreamStatus: res.status
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
client.core.tenant = tenant;
|
|
539
|
+
return tenant;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Builds the Cumulocity Options API path for a category (and optional key).
|
|
543
|
+
* @param category - The settings category
|
|
544
|
+
* @param key - The tenant option key (omit for category-level operations)
|
|
545
|
+
*/
|
|
546
|
+
function optionsPath(category, key) {
|
|
547
|
+
const base = `/tenant/options/${encodeURIComponent(category)}`;
|
|
548
|
+
return key === void 0 ? base : `${base}/${encodeURIComponent(key)}`;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Cumulocity role required to read tenant options.
|
|
552
|
+
*/
|
|
553
|
+
const OPTION_READ_ROLE = "ROLE_OPTION_MANAGEMENT_READ";
|
|
554
|
+
/**
|
|
555
|
+
* Cumulocity role required to create, update or delete tenant options.
|
|
556
|
+
*/
|
|
557
|
+
const OPTION_ADMIN_ROLE = "ROLE_OPTION_MANAGEMENT_ADMIN";
|
|
558
|
+
/**
|
|
559
|
+
* Builds a structured error for a failed Options API request.
|
|
560
|
+
*
|
|
561
|
+
* A failed options request is a microservice/config problem (e.g. a missing
|
|
562
|
+
* `ROLE_OPTION_MANAGEMENT_*` role), not something the frontend caller can act on
|
|
563
|
+
* — so the response is a **generic 500** that reveals nothing about tenant
|
|
564
|
+
* options. All the diagnostic detail (which operation, upstream status, internal
|
|
565
|
+
* endpoint/method, and the Cumulocity role it needs) goes into `internal`, which
|
|
566
|
+
* is logged for debugging but never serialized to the HTTP response.
|
|
567
|
+
* @param status - The upstream HTTP status (for `internal`)
|
|
568
|
+
* @param operation - A human-readable summary of the failed operation (for `internal`)
|
|
569
|
+
* @param internal - Backend-only debugging context (endpoint, method, required role)
|
|
570
|
+
*/
|
|
571
|
+
function optionRequestError(status, operation, internal) {
|
|
572
|
+
return createError({
|
|
573
|
+
status: 500,
|
|
574
|
+
message: "Internal Server Error",
|
|
575
|
+
internal: {
|
|
576
|
+
operation,
|
|
577
|
+
upstreamStatus: status,
|
|
578
|
+
...internal
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Reads a single option value directly from the Options API.
|
|
584
|
+
* Returns `undefined` when the option is not set (404).
|
|
585
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
586
|
+
* @param category - The settings category
|
|
587
|
+
* @param key - The tenant option key
|
|
588
|
+
*/
|
|
589
|
+
async function fetchOptionValue(client, category, key) {
|
|
590
|
+
const endpoint = optionsPath(category, key);
|
|
591
|
+
const res = await client.core.fetch(endpoint, {
|
|
592
|
+
method: "GET",
|
|
593
|
+
headers: { Accept: "application/json" }
|
|
594
|
+
});
|
|
595
|
+
if (res.status === 404) return;
|
|
596
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to read tenant option '${key}'`, {
|
|
597
|
+
method: "GET",
|
|
598
|
+
endpoint,
|
|
599
|
+
requiredRole: OPTION_READ_ROLE
|
|
600
|
+
});
|
|
601
|
+
return (await res.json())?.value;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Creates or updates a single option value (upsert) via the Options API.
|
|
605
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
606
|
+
* @param category - The settings category
|
|
607
|
+
* @param key - The tenant option key
|
|
608
|
+
* @param value - The value to store
|
|
609
|
+
*/
|
|
610
|
+
async function putOptionValue(client, category, key, value) {
|
|
611
|
+
const endpoint = optionsPath(category, key);
|
|
612
|
+
const res = await client.core.fetch(endpoint, {
|
|
613
|
+
method: "PUT",
|
|
614
|
+
headers: {
|
|
615
|
+
"Content-Type": "application/json",
|
|
616
|
+
"Accept": "application/json"
|
|
617
|
+
},
|
|
618
|
+
body: JSON.stringify({ value })
|
|
619
|
+
});
|
|
620
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to set tenant option '${key}'`, {
|
|
621
|
+
method: "PUT",
|
|
622
|
+
endpoint,
|
|
623
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Deletes a single option via the Options API. Idempotent: a 404 resolves.
|
|
628
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
629
|
+
* @param category - The settings category
|
|
630
|
+
* @param key - The tenant option key
|
|
631
|
+
*/
|
|
632
|
+
async function deleteOptionValue(client, category, key) {
|
|
633
|
+
const endpoint = optionsPath(category, key);
|
|
634
|
+
const res = await client.core.fetch(endpoint, {
|
|
635
|
+
method: "DELETE",
|
|
636
|
+
headers: { Accept: "application/json" }
|
|
637
|
+
});
|
|
638
|
+
if (res.status === 404) return;
|
|
639
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to delete tenant option '${key}'`, {
|
|
640
|
+
method: "DELETE",
|
|
641
|
+
endpoint,
|
|
642
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Updates the `editable` flag of a single option (management tenant only).
|
|
647
|
+
* @param client - The Cumulocity client (must be the management tenant)
|
|
648
|
+
* @param category - The settings category
|
|
649
|
+
* @param key - The tenant option key
|
|
650
|
+
* @param editable - Whether the option may be edited
|
|
651
|
+
* @param targetTenant - The tenant whose option flag is updated
|
|
652
|
+
*/
|
|
653
|
+
async function putOptionEditable(client, category, key, editable, targetTenant) {
|
|
654
|
+
const endpoint = `${optionsPath(category, key)}/editable`;
|
|
655
|
+
const res = await client.core.fetch(`${endpoint}?targetTenant=${encodeURIComponent(targetTenant)}`, {
|
|
656
|
+
method: "PUT",
|
|
657
|
+
headers: {
|
|
658
|
+
"Content-Type": "application/json",
|
|
659
|
+
"Accept": "application/json"
|
|
660
|
+
},
|
|
661
|
+
body: JSON.stringify({ editable })
|
|
662
|
+
});
|
|
663
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to update editable flag for tenant option '${key}'`, {
|
|
664
|
+
method: "PUT",
|
|
665
|
+
endpoint,
|
|
666
|
+
targetTenant,
|
|
667
|
+
requiredRole: `${OPTION_ADMIN_ROLE} (management tenant only)`
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Lists all options set in a category as a key/value map.
|
|
672
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
673
|
+
* @param category - The settings category
|
|
674
|
+
*/
|
|
675
|
+
async function listCategoryOptions(client, category) {
|
|
676
|
+
const endpoint = optionsPath(category);
|
|
677
|
+
const res = await client.core.fetch(endpoint, {
|
|
678
|
+
method: "GET",
|
|
679
|
+
headers: { Accept: "application/json" }
|
|
680
|
+
});
|
|
681
|
+
if (res.status === 404) return {};
|
|
682
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to list tenant options for category '${category}'`, {
|
|
683
|
+
method: "GET",
|
|
684
|
+
endpoint,
|
|
685
|
+
requiredRole: OPTION_READ_ROLE
|
|
686
|
+
});
|
|
687
|
+
return { ...await res.json() };
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
409
690
|
* Gets the cache TTL for a specific tenant option key.
|
|
410
|
-
* Uses per-key override if defined, otherwise falls back to default TTL.
|
|
691
|
+
* Uses per-key override if defined, otherwise falls back to the default TTL.
|
|
411
692
|
* @param key - The tenant option key
|
|
412
693
|
*/
|
|
413
694
|
function getTenantOptionCacheTTL(key) {
|
|
@@ -415,24 +696,16 @@ function getTenantOptionCacheTTL(key) {
|
|
|
415
696
|
return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
|
|
416
697
|
}
|
|
417
698
|
/**
|
|
418
|
-
* Factory
|
|
699
|
+
* Factory that creates a cached read fetcher for a specific tenant/category/key.
|
|
700
|
+
* All fetchers share the same cache group so they can be invalidated together.
|
|
701
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
702
|
+
* @param tenant - The tenant id (for the cache key)
|
|
703
|
+
* @param category - The settings category
|
|
419
704
|
* @param key - The tenant option key
|
|
420
705
|
*/
|
|
421
|
-
function createCachedTenantOptionFetcher(key) {
|
|
422
|
-
const cacheName = `_c8y_nitro_tenant_option_${key.replace(
|
|
423
|
-
const cachedFetcher = defineCachedFunction(async () => {
|
|
424
|
-
const client = await useDeployedTenantClient();
|
|
425
|
-
const category = useRuntimeConfig().c8ySettingsCategory;
|
|
426
|
-
try {
|
|
427
|
-
return (await client.options.tenant.detail({
|
|
428
|
-
key,
|
|
429
|
-
category
|
|
430
|
-
})).data.value;
|
|
431
|
-
} catch (error) {
|
|
432
|
-
if (error?.res?.status === 404 || error?.status === 404) return;
|
|
433
|
-
throw error;
|
|
434
|
-
}
|
|
435
|
-
}, {
|
|
706
|
+
function createCachedTenantOptionFetcher(client, tenant, category, key) {
|
|
707
|
+
const cacheName = `_c8y_nitro_tenant_option_${fetcherStoreKey(tenant, category, key).replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
708
|
+
const cachedFetcher = defineCachedFunction(async () => fetchOptionValue(client, category, key), {
|
|
436
709
|
maxAge: getTenantOptionCacheTTL(key),
|
|
437
710
|
name: cacheName,
|
|
438
711
|
group: "c8y_nitro",
|
|
@@ -444,24 +717,122 @@ function createCachedTenantOptionFetcher(key) {
|
|
|
444
717
|
} });
|
|
445
718
|
}
|
|
446
719
|
/**
|
|
447
|
-
* Gets or creates
|
|
720
|
+
* Gets or creates the cached read fetcher for a tenant/category/key triple.
|
|
721
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
722
|
+
* @param tenant - The resolved tenant id
|
|
723
|
+
* @param category - The settings category
|
|
448
724
|
* @param key - The tenant option key
|
|
449
725
|
*/
|
|
450
|
-
function getOrCreateFetcher(key) {
|
|
451
|
-
|
|
726
|
+
function getOrCreateFetcher(client, tenant, category, key) {
|
|
727
|
+
const storeKey = fetcherStoreKey(tenant, category, key);
|
|
728
|
+
let fetcher = tenantOptionFetchers[storeKey];
|
|
452
729
|
if (!fetcher) {
|
|
453
|
-
fetcher = createCachedTenantOptionFetcher(key);
|
|
454
|
-
tenantOptionFetchers[
|
|
730
|
+
fetcher = createCachedTenantOptionFetcher(client, tenant, category, key);
|
|
731
|
+
tenantOptionFetchers[storeKey] = fetcher;
|
|
455
732
|
}
|
|
456
733
|
return fetcher;
|
|
457
734
|
}
|
|
458
735
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
|
|
736
|
+
* Invalidates the cached read for a store key, if one has been created.
|
|
737
|
+
* @param storeKey - The composite `${tenant}::${category}::${key}` store key
|
|
738
|
+
*/
|
|
739
|
+
async function invalidateStoreKey(storeKey) {
|
|
740
|
+
const fetcher = tenantOptionFetchers[storeKey];
|
|
741
|
+
if (fetcher) await fetcher.invalidate();
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Invalidates every created fetcher whose store key matches the predicate.
|
|
745
|
+
* @param predicate - Selects store keys to invalidate
|
|
746
|
+
*/
|
|
747
|
+
async function invalidateFetchers(predicate) {
|
|
748
|
+
await Promise.all(Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey)).map(([, fetcher]) => fetcher.invalidate()));
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Refreshes every created fetcher whose store key matches the predicate.
|
|
752
|
+
* @param predicate - Selects store keys to refresh
|
|
753
|
+
* @returns Map of matched store keys to their refreshed values
|
|
754
|
+
*/
|
|
755
|
+
async function refreshFetchers(predicate) {
|
|
756
|
+
const entries = Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey));
|
|
757
|
+
const values = await Promise.all(entries.map(([, fetcher]) => fetcher.refresh()));
|
|
758
|
+
return Object.fromEntries(entries.map(([storeKey], i) => [storeKey, values[i]]));
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Builds a single-option handle for a client/category/key.
|
|
762
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
763
|
+
* @param category - The settings category
|
|
764
|
+
* @param key - The tenant option key
|
|
765
|
+
*/
|
|
766
|
+
function makeTenantOption(client, category, key) {
|
|
767
|
+
const currentStoreKey = async () => fetcherStoreKey(await resolveClientTenant(client), category, key);
|
|
768
|
+
const readFetcher = async () => getOrCreateFetcher(client, await resolveClientTenant(client), category, key);
|
|
769
|
+
return {
|
|
770
|
+
read: async () => (await readFetcher())(),
|
|
771
|
+
set: async (value) => {
|
|
772
|
+
await putOptionValue(client, category, key, value);
|
|
773
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
774
|
+
},
|
|
775
|
+
getOrInsert: async (fallback) => {
|
|
776
|
+
const current = await (await readFetcher())();
|
|
777
|
+
if (current !== void 0) return current;
|
|
778
|
+
await putOptionValue(client, category, key, fallback);
|
|
779
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
780
|
+
return fallback;
|
|
781
|
+
},
|
|
782
|
+
delete: async () => {
|
|
783
|
+
await deleteOptionValue(client, category, key);
|
|
784
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
785
|
+
},
|
|
786
|
+
setEditable: async (editable, options) => {
|
|
787
|
+
await putOptionEditable(client, category, key, editable, options.targetTenant);
|
|
788
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
789
|
+
},
|
|
790
|
+
refresh: async () => (await readFetcher()).refresh(),
|
|
791
|
+
invalidate: async () => invalidateStoreKey(await currentStoreKey())
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Builds a category handle. The runtime shape is identical for the own and
|
|
796
|
+
* foreign categories; the public overloads differ only at the type level.
|
|
797
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
798
|
+
* @param category - The settings category
|
|
799
|
+
*/
|
|
800
|
+
function makeTenantOptionCategory(client, category) {
|
|
801
|
+
const categoryPrefix = async () => `${await resolveClientTenant(client)}::${category}::`;
|
|
802
|
+
return {
|
|
803
|
+
option: (key) => makeTenantOption(client, category, key),
|
|
804
|
+
list: () => listCategoryOptions(client, category),
|
|
805
|
+
setAll: async (values) => {
|
|
806
|
+
const entries = Object.entries(values);
|
|
807
|
+
await Promise.all(entries.map(([key, value]) => putOptionValue(client, category, key, value)));
|
|
808
|
+
const tenant = await resolveClientTenant(client);
|
|
809
|
+
await Promise.all(entries.map(([key]) => invalidateStoreKey(fetcherStoreKey(tenant, category, key))));
|
|
810
|
+
},
|
|
811
|
+
invalidateAll: async () => {
|
|
812
|
+
const prefix = await categoryPrefix();
|
|
813
|
+
await invalidateFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
814
|
+
},
|
|
815
|
+
refreshAll: async () => {
|
|
816
|
+
const prefix = await categoryPrefix();
|
|
817
|
+
const refreshed = await refreshFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
818
|
+
return Object.fromEntries(Object.entries(refreshed).map(([storeKey, value]) => [storeKey.slice(prefix.length), value]));
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Gets a handle for a single tenant option in the microservice's **own** settings
|
|
824
|
+
* category, targeting the tenant of the given `client`.\
|
|
825
|
+
* Reads are cached per tenant, using the client's service user credentials.
|
|
462
826
|
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
827
|
+
* This is a shortcut for `useTenantOptions(client).option(key)` — it only drops
|
|
828
|
+
* the category argument. For a single option in a **foreign** category, use
|
|
829
|
+
* `useTenantOptions(client, category).option(key)`.
|
|
830
|
+
*
|
|
831
|
+
* Manifest-declared keys are offered via autocomplete, but any dynamic string
|
|
832
|
+
* is accepted too (e.g. `encrypted.password.${hash}`).
|
|
833
|
+
*
|
|
834
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
835
|
+
* @param key - The tenant option key
|
|
465
836
|
*
|
|
466
837
|
* @config Cache TTL can be configured via:
|
|
467
838
|
* - `c8y.cache.defaultTenantOptionsTTL` — Default TTL for all keys (in seconds)
|
|
@@ -469,59 +840,48 @@ function getOrCreateFetcher(key) {
|
|
|
469
840
|
* - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
|
|
470
841
|
*
|
|
471
842
|
* @example
|
|
472
|
-
* //
|
|
473
|
-
* const value = await useTenantOption('
|
|
843
|
+
* // Read the owner tenant's config:
|
|
844
|
+
* const value = await useTenantOption(await useDeployedTenantClient(), 'featureMode').read()
|
|
845
|
+
*
|
|
846
|
+
* // Read the current request tenant's config (multi-tenant):
|
|
847
|
+
* const value = await useTenantOption(await useUserTenantClient(event), 'featureMode').read()
|
|
474
848
|
*
|
|
475
|
-
* //
|
|
476
|
-
*
|
|
849
|
+
* // Create or update, or seed a dynamic key:
|
|
850
|
+
* await useTenantOption(client, 'featureMode').set('advanced')
|
|
851
|
+
* const token = await useTenantOption(client, `encrypted.password.${hash}`).getOrInsert('')
|
|
852
|
+
*/
|
|
853
|
+
function useTenantOption(client, key) {
|
|
854
|
+
return makeTenantOption(client, getOwnCategory(), key);
|
|
855
|
+
}
|
|
856
|
+
const useTenantOptionsBase = ((client, category) => makeTenantOptionCategory(client, category ?? getOwnCategory()));
|
|
857
|
+
/**
|
|
858
|
+
* Gets a handle for a whole settings category, targeting the tenant of the given
|
|
859
|
+
* `client`.\
|
|
860
|
+
* Omit the category (or pass the own {@link C8YSettingsCategory}) for the
|
|
861
|
+
* microservice's own category; pass any other category to read/write options in
|
|
862
|
+
* a foreign category (where `credentials.*` keys are rejected).
|
|
863
|
+
*
|
|
864
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
477
865
|
*
|
|
478
|
-
*
|
|
479
|
-
* await
|
|
866
|
+
* @example
|
|
867
|
+
* const client = await useUserTenantClient(event)
|
|
868
|
+
*
|
|
869
|
+
* // Own category:
|
|
870
|
+
* const values = await useTenantOptions(client).list()
|
|
871
|
+
* await useTenantOptions(client).option('featureMode').set('advanced')
|
|
480
872
|
*
|
|
481
|
-
* //
|
|
482
|
-
* const
|
|
873
|
+
* // Foreign category:
|
|
874
|
+
* const value = await useTenantOptions(client, 'other-service').option('someKey').read()
|
|
483
875
|
*
|
|
484
|
-
* // Invalidate all tenant
|
|
485
|
-
* await
|
|
876
|
+
* // Invalidate all cached options of one tenant + category:
|
|
877
|
+
* await useTenantOptions(client, 'other-service').invalidateAll()
|
|
486
878
|
*
|
|
487
|
-
* //
|
|
488
|
-
*
|
|
879
|
+
* // Invalidate everything, across all tenants and categories:
|
|
880
|
+
* await useTenantOptions.invalidateAll()
|
|
489
881
|
*/
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Invalidate the cache for a specific tenant option key.
|
|
495
|
-
* @param key - The tenant option key to invalidate
|
|
496
|
-
*/
|
|
497
|
-
invalidate: async (key) => {
|
|
498
|
-
const fetcher = tenantOptionFetchers[key];
|
|
499
|
-
if (fetcher) await fetcher.invalidate();
|
|
500
|
-
},
|
|
501
|
-
/**
|
|
502
|
-
* Force refresh a specific tenant option key (invalidates and re-fetches).
|
|
503
|
-
* @param key - The tenant option key to refresh
|
|
504
|
-
*/
|
|
505
|
-
refresh: async (key) => {
|
|
506
|
-
return await getOrCreateFetcher(key).refresh();
|
|
507
|
-
},
|
|
508
|
-
/**
|
|
509
|
-
* Invalidate all tenant option caches that have been accessed.
|
|
510
|
-
* Only invalidates keys that have been fetched at least once.
|
|
511
|
-
*/
|
|
512
|
-
invalidateAll: async () => {
|
|
513
|
-
await Promise.all(Object.values(tenantOptionFetchers).map((fetcher) => fetcher?.invalidate()));
|
|
514
|
-
},
|
|
515
|
-
/**
|
|
516
|
-
* Refresh all tenant options that have been accessed.
|
|
517
|
-
* Only refreshes keys that have been fetched at least once.
|
|
518
|
-
* @returns Object mapping keys to their refreshed values
|
|
519
|
-
*/
|
|
520
|
-
refreshAll: async () => {
|
|
521
|
-
const entries = Object.entries(tenantOptionFetchers);
|
|
522
|
-
const values = await Promise.all(entries.map(([, fetcher]) => fetcher?.refresh()));
|
|
523
|
-
return Object.fromEntries(entries.map(([key], i) => [key, values[i]]));
|
|
524
|
-
}
|
|
882
|
+
const useTenantOptions = Object.assign(useTenantOptionsBase, {
|
|
883
|
+
invalidateAll: () => invalidateFetchers(() => true),
|
|
884
|
+
refreshAll: () => refreshFetchers(() => true)
|
|
525
885
|
});
|
|
526
886
|
//#endregion
|
|
527
887
|
//#region src/utils/schedule.ts
|
|
@@ -688,4 +1048,4 @@ async function cancelScheduledTask(id) {
|
|
|
688
1048
|
return cancelled;
|
|
689
1049
|
}
|
|
690
1050
|
//#endregion
|
|
691
|
-
export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
|
|
1051
|
+
export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
|