c8y-nitro 0.5.0 → 0.7.0
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 +17 -514
- package/dist/{bootstrap-BSdVwFWO.mjs → bootstrap-ObaEF7rN.mjs} +6 -6
- package/dist/{c8y-api-BgTNTqHd.mjs → c8y-api-BFjK6AYb.mjs} +42 -6
- package/dist/cli/index.mjs +4 -4
- package/dist/credentials-eKnJ7m9Y.d.mts +5 -0
- package/dist/{index-Bvu7DqDt.d.mts → index-ByDSMEvU.d.mts} +21 -5
- package/dist/index.d.mts +7 -2
- package/dist/index.mjs +163 -35
- package/dist/{options-BkWL1FOa.mjs → options-B7_yb_wr.mjs} +4 -4
- package/dist/{package-bfMasPPg.mjs → package-BhFLRPOb.mjs} +1 -1
- package/dist/{roles-CoBPqvDv.mjs → roles-B58Fguop.mjs} +4 -4
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +616 -5
- 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 -2
- package/dist/utils.d.mts +309 -63
- package/dist/utils.mjs +577 -114
- package/package.json +23 -15
- /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/{logging-CVlaRS4O.mjs → logging-DYnYICoX.mjs} +0 -0
|
@@ -1,17 +1,629 @@
|
|
|
1
|
-
import { t as createError } from "../../logging-
|
|
1
|
+
import { t as createError } from "../../logging-DYnYICoX.mjs";
|
|
2
2
|
import { c8yTenantOptionKeys } from "c8y-nitro/runtime";
|
|
3
|
+
import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
|
|
4
|
+
import { defineCachedFunction } from "nitro/cache";
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
9
|
+
import { useNitroHooks } from "nitro/app";
|
|
3
10
|
import { defineEventHandler, getQuery } from "nitro/h3";
|
|
11
|
+
//#region src/utils/internal/common.ts
|
|
12
|
+
/**
|
|
13
|
+
* Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
|
|
14
|
+
* Extracts the following headers from the request:
|
|
15
|
+
* - `authorization`: Used for Basic Auth or Bearer token authentication
|
|
16
|
+
* - `cookie`: Used to extract XSRF-TOKEN and authorization token from cookies
|
|
17
|
+
*
|
|
18
|
+
* The MicroserviceClientRequestAuth class will automatically:
|
|
19
|
+
* - Extract XSRF-TOKEN from cookies for CSRF protection
|
|
20
|
+
* - Extract authorization token from cookies (prioritized over header auth)
|
|
21
|
+
* - Fall back to Authorization header if no cookie-based auth is present
|
|
22
|
+
*
|
|
23
|
+
* @param request - The HTTP request containing headers
|
|
24
|
+
* @returns Headers object compatible with \@c8y/client's MicroserviceClientRequestAuth
|
|
25
|
+
*/
|
|
26
|
+
function convertRequestHeadersToC8yFormat(request) {
|
|
27
|
+
const headers = {};
|
|
28
|
+
const authorization = request.headers.get("authorization");
|
|
29
|
+
if (authorization) headers.authorization = authorization;
|
|
30
|
+
const cookie = request.headers.get("cookie");
|
|
31
|
+
if (cookie) headers.cookie = cookie;
|
|
32
|
+
return headers;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/utils/internal/tenant.ts
|
|
36
|
+
const USER_TENANT_CACHE_SALT = randomBytes(32).toString("hex");
|
|
37
|
+
function getCookieValue(cookieHeader, name) {
|
|
38
|
+
try {
|
|
39
|
+
const value = cookieHeader?.match(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`);
|
|
40
|
+
return value ? value.pop() : void 0;
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function getCurrentUserTenantCacheKeyMaterial(requestOrEvent) {
|
|
46
|
+
const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
|
|
47
|
+
const cookieAuth = getCookieValue(request.headers.get("cookie"), "authorization");
|
|
48
|
+
if (cookieAuth) return `cookie:${cookieAuth}`;
|
|
49
|
+
const authorization = request.headers.get("authorization");
|
|
50
|
+
if (authorization) return `header:${authorization}`;
|
|
51
|
+
}
|
|
52
|
+
function createCurrentUserTenantCacheKey(requestOrEvent) {
|
|
53
|
+
const material = getCurrentUserTenantCacheKeyMaterial(requestOrEvent);
|
|
54
|
+
if (!material) throw new Error("Cannot create current user tenant cache key without auth material");
|
|
55
|
+
return createHash("sha256").update(USER_TENANT_CACHE_SALT).update(":").update(material).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
function tryGetTenantFromBasicAuth(requestOrEvent) {
|
|
58
|
+
const authorization = ("req" in requestOrEvent ? requestOrEvent.req : requestOrEvent).headers.get("authorization");
|
|
59
|
+
if (!authorization?.startsWith("Basic ")) return;
|
|
60
|
+
try {
|
|
61
|
+
const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
|
|
62
|
+
const separatorIndex = decoded.indexOf(":");
|
|
63
|
+
const userPart = separatorIndex === -1 ? decoded : decoded.slice(0, separatorIndex);
|
|
64
|
+
const slashIndex = userPart.indexOf("/");
|
|
65
|
+
if (slashIndex <= 0) return;
|
|
66
|
+
return userPart.slice(0, slashIndex);
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
defineCachedFunction(async (requestOrEvent) => {
|
|
72
|
+
const basicTenant = tryGetTenantFromBasicAuth(requestOrEvent);
|
|
73
|
+
if (basicTenant) return basicTenant;
|
|
74
|
+
return (await useUserClient(requestOrEvent).tenant.current()).data.name;
|
|
75
|
+
}, {
|
|
76
|
+
maxAge: 60,
|
|
77
|
+
name: "_c8y_nitro_get_current_user_tenant_id",
|
|
78
|
+
group: "c8y_nitro",
|
|
79
|
+
swr: false,
|
|
80
|
+
getKey: (requestOrEvent) => createCurrentUserTenantCacheKey(requestOrEvent),
|
|
81
|
+
shouldBypassCache: (requestOrEvent) => !getCurrentUserTenantCacheKeyMaterial(requestOrEvent)
|
|
82
|
+
});
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/utils/credentials.ts
|
|
85
|
+
let prevCredentials = null;
|
|
86
|
+
function shouldEmitTenantCredentialsUpdated(prev, next) {
|
|
87
|
+
if (!prev) return true;
|
|
88
|
+
const prevTenantIds = Object.keys(prev);
|
|
89
|
+
const nextTenantIds = Object.keys(next);
|
|
90
|
+
if (prevTenantIds.length !== nextTenantIds.length) return true;
|
|
91
|
+
return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fetches credentials for all tenants subscribed to this microservice.\
|
|
95
|
+
* Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
|
|
96
|
+
* Results are cached based on the configured TTL (default: 10 minutes).\
|
|
97
|
+
* @returns Object mapping tenant IDs to their respective credentials
|
|
98
|
+
* @config Cache TTL can be configured via:
|
|
99
|
+
* - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
|
|
100
|
+
* - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
|
|
101
|
+
* @example
|
|
102
|
+
* // Get all subscribed tenant credentials:
|
|
103
|
+
* const credentials = await useSubscribedTenantCredentials()
|
|
104
|
+
* console.log(Object.keys(credentials)) // ['t12345', 't67890']
|
|
105
|
+
*
|
|
106
|
+
* // Access specific tenant:
|
|
107
|
+
* const tenant1Creds = credentials['t12345']
|
|
108
|
+
*
|
|
109
|
+
* // Invalidate cache:
|
|
110
|
+
* await useSubscribedTenantCredentials.invalidate()
|
|
111
|
+
*
|
|
112
|
+
* // Force refresh:
|
|
113
|
+
* const freshCreds = await useSubscribedTenantCredentials.refresh()
|
|
114
|
+
*/
|
|
115
|
+
const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
|
|
116
|
+
const newCredentials = (await Client.getMicroserviceSubscriptions({
|
|
117
|
+
tenant: process.env.C8Y_BOOTSTRAP_TENANT,
|
|
118
|
+
user: process.env.C8Y_BOOTSTRAP_USER,
|
|
119
|
+
password: process.env.C8Y_BOOTSTRAP_PASSWORD
|
|
120
|
+
}, process.env.C8Y_BASEURL)).reduce((acc, cred) => {
|
|
121
|
+
if (cred.tenant) acc[cred.tenant] = cred;
|
|
122
|
+
return acc;
|
|
123
|
+
}, {});
|
|
124
|
+
if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
|
|
125
|
+
prevCredentials = newCredentials;
|
|
126
|
+
return newCredentials;
|
|
127
|
+
}, {
|
|
128
|
+
maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
|
|
129
|
+
name: "_c8y_nitro_get_subscribed_tenant_credentials",
|
|
130
|
+
group: "c8y_nitro",
|
|
131
|
+
swr: false
|
|
132
|
+
});
|
|
133
|
+
const useSubscribedTenantCredentials = Object.assign(async () => {
|
|
134
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
135
|
+
prevCredentials = credentials;
|
|
136
|
+
return credentials;
|
|
137
|
+
}, {
|
|
138
|
+
invalidate: cachedSubscribedTenantCredentials.invalidate,
|
|
139
|
+
refresh: async () => {
|
|
140
|
+
await cachedSubscribedTenantCredentials.invalidate();
|
|
141
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
142
|
+
prevCredentials = credentials;
|
|
143
|
+
return credentials;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* Fetches credentials for the tenant where this microservice is deployed.\
|
|
148
|
+
* Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
|
|
149
|
+
* Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
|
|
150
|
+
* @returns Credentials for the deployed tenant
|
|
151
|
+
* @throws {EvlogError} If no credentials found for the deployed tenant
|
|
152
|
+
* @example
|
|
153
|
+
* // Get deployed tenant credentials:
|
|
154
|
+
* const creds = await useDeployedTenantCredentials()
|
|
155
|
+
* console.log(creds.tenant, creds.user)
|
|
156
|
+
*
|
|
157
|
+
* // Invalidate cache:
|
|
158
|
+
* await useDeployedTenantCredentials.invalidate()
|
|
159
|
+
*
|
|
160
|
+
* // Force refresh:
|
|
161
|
+
* const freshCreds = await useDeployedTenantCredentials.refresh()
|
|
162
|
+
* @note This function is not cached separately. It uses the cache of `useSubscribedTenantCredentials()`. Invalidating or refreshing one will refresh `useDeployedTenantCredentials()`s cache.
|
|
163
|
+
*/
|
|
164
|
+
const useDeployedTenantCredentials = Object.assign(async () => {
|
|
165
|
+
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
166
|
+
const allCredsPromise = await useSubscribedTenantCredentials();
|
|
167
|
+
if (!allCredsPromise[tenant]) throw createError({
|
|
168
|
+
message: `No credentials found for tenant deployed tenant '${tenant}'`,
|
|
169
|
+
status: 500
|
|
170
|
+
});
|
|
171
|
+
return allCredsPromise[tenant];
|
|
172
|
+
}, {
|
|
173
|
+
invalidate: useSubscribedTenantCredentials.invalidate,
|
|
174
|
+
refresh: async () => {
|
|
175
|
+
await useSubscribedTenantCredentials.refresh();
|
|
176
|
+
return await useDeployedTenantCredentials();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/utils/client.ts
|
|
181
|
+
/**
|
|
182
|
+
* Creates a Cumulocity client authenticated with the current user's credentials.\
|
|
183
|
+
* Extracts credentials from the Authorization header of the current request.
|
|
184
|
+
* @param requestOrEvent - The H3Event or ServerRequest from the current request
|
|
185
|
+
* @returns A configured Cumulocity Client instance
|
|
186
|
+
* @example
|
|
187
|
+
* // In a request handler:
|
|
188
|
+
* const client = useUserClient(event)
|
|
189
|
+
* const { data } = await client.inventory.list()
|
|
190
|
+
*/
|
|
191
|
+
function useUserClient(requestOrEvent) {
|
|
192
|
+
const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
|
|
193
|
+
if (request.context?.["c8y_user_client"]) return request.context["c8y_user_client"];
|
|
194
|
+
const client = new Client(new MicroserviceClientRequestAuth(convertRequestHeadersToC8yFormat(request)), process.env.C8Y_BASEURL);
|
|
195
|
+
request.context ??= {};
|
|
196
|
+
request.context["c8y_user_client"] = client;
|
|
197
|
+
return client;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Creates a Cumulocity client for the tenant where this microservice is deployed.\
|
|
201
|
+
* Uses the bootstrap tenant ID from runtime config to identify the deployed tenant.\
|
|
202
|
+
* @returns A configured Cumulocity Client instance for the deployed tenant
|
|
203
|
+
* @example
|
|
204
|
+
* // Get client for the tenant hosting this microservice:
|
|
205
|
+
* const client = await useDeployedTenantClient()
|
|
206
|
+
* const { data } = await client.application.list()
|
|
207
|
+
*/
|
|
208
|
+
async function useDeployedTenantClient() {
|
|
209
|
+
const creds = await useSubscribedTenantCredentials();
|
|
210
|
+
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
211
|
+
if (!creds[tenant]) throw createError({
|
|
212
|
+
message: `No subscribed tenant credentials found for tenant '${tenant}'`,
|
|
213
|
+
status: 500
|
|
214
|
+
});
|
|
215
|
+
const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
|
|
216
|
+
client.core.tenant = tenant;
|
|
217
|
+
return client;
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
4
220
|
//#region src/utils/internal/tenantOptionFetchers.ts
|
|
5
221
|
/**
|
|
6
|
-
* Internal storage for cached
|
|
222
|
+
* Internal storage for cached option fetchers, keyed by
|
|
223
|
+
* `${tenant}::${category}::${key}`.
|
|
224
|
+
*
|
|
225
|
+
* Including the tenant and category in the key keeps options from different
|
|
226
|
+
* tenants (multi-tenancy) and settings categories from colliding on a shared
|
|
227
|
+
* `key`.
|
|
7
228
|
*/
|
|
8
229
|
const tenantOptionFetchers = {};
|
|
230
|
+
/**
|
|
231
|
+
* Builds the composite store key for a tenant/category/key triple.
|
|
232
|
+
* @param tenant - The tenant id owning the option
|
|
233
|
+
* @param category - The settings category
|
|
234
|
+
* @param key - The tenant option key
|
|
235
|
+
*/
|
|
236
|
+
function fetcherStoreKey(tenant, category, key) {
|
|
237
|
+
return `${tenant}::${category}::${key}`;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/utils/tenantOptions.ts
|
|
241
|
+
/**
|
|
242
|
+
* The microservice's own settings category, resolved once from runtime config
|
|
243
|
+
* (`manifest.settingsCategory` → `contextPath` → `name`). This is independent of
|
|
244
|
+
* which tenant is targeted.
|
|
245
|
+
*/
|
|
246
|
+
function getOwnCategory() {
|
|
247
|
+
return useRuntimeConfig().c8ySettingsCategory;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Resolves the tenant id a client operates on, used for per-tenant cache keys.
|
|
251
|
+
*
|
|
252
|
+
* Prefers the stamped `client.core.tenant` (set by the `use*Client` helpers);
|
|
253
|
+
* otherwise resolves it on demand via `/tenant/currentTenant` and memoizes it
|
|
254
|
+
* back onto the client.
|
|
255
|
+
* @param client - The Cumulocity client
|
|
256
|
+
*/
|
|
257
|
+
async function resolveClientTenant(client) {
|
|
258
|
+
if (client.core.tenant) return client.core.tenant;
|
|
259
|
+
const res = await client.core.fetch("/tenant/currentTenant", {
|
|
260
|
+
method: "GET",
|
|
261
|
+
headers: { Accept: "application/json" }
|
|
262
|
+
});
|
|
263
|
+
const tenant = res.ok ? (await res.json())?.name : void 0;
|
|
264
|
+
if (!tenant) throw createError({
|
|
265
|
+
status: 500,
|
|
266
|
+
message: "Could not determine the target tenant",
|
|
267
|
+
fix: "This is unexpected. Retry the request, and contact the microservice operator if it keeps happening.",
|
|
268
|
+
internal: {
|
|
269
|
+
reason: res.ok ? "GET /tenant/currentTenant returned no tenant name" : "GET /tenant/currentTenant failed",
|
|
270
|
+
upstreamStatus: res.status
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
client.core.tenant = tenant;
|
|
274
|
+
return tenant;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Builds the Cumulocity Options API path for a category (and optional key).
|
|
278
|
+
* @param category - The settings category
|
|
279
|
+
* @param key - The tenant option key (omit for category-level operations)
|
|
280
|
+
*/
|
|
281
|
+
function optionsPath(category, key) {
|
|
282
|
+
const base = `/tenant/options/${encodeURIComponent(category)}`;
|
|
283
|
+
return key === void 0 ? base : `${base}/${encodeURIComponent(key)}`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Cumulocity role required to read tenant options.
|
|
287
|
+
*/
|
|
288
|
+
const OPTION_READ_ROLE = "ROLE_OPTION_MANAGEMENT_READ";
|
|
289
|
+
/**
|
|
290
|
+
* Cumulocity role required to create, update or delete tenant options.
|
|
291
|
+
*/
|
|
292
|
+
const OPTION_ADMIN_ROLE = "ROLE_OPTION_MANAGEMENT_ADMIN";
|
|
293
|
+
/**
|
|
294
|
+
* Builds a structured error for a failed Options API request.
|
|
295
|
+
*
|
|
296
|
+
* A failed options request is a microservice/config problem (e.g. a missing
|
|
297
|
+
* `ROLE_OPTION_MANAGEMENT_*` role), not something the frontend caller can act on
|
|
298
|
+
* — so the response is a **generic 500** that reveals nothing about tenant
|
|
299
|
+
* options. All the diagnostic detail (which operation, upstream status, internal
|
|
300
|
+
* endpoint/method, and the Cumulocity role it needs) goes into `internal`, which
|
|
301
|
+
* is logged for debugging but never serialized to the HTTP response.
|
|
302
|
+
* @param status - The upstream HTTP status (for `internal`)
|
|
303
|
+
* @param operation - A human-readable summary of the failed operation (for `internal`)
|
|
304
|
+
* @param internal - Backend-only debugging context (endpoint, method, required role)
|
|
305
|
+
*/
|
|
306
|
+
function optionRequestError(status, operation, internal) {
|
|
307
|
+
return createError({
|
|
308
|
+
status: 500,
|
|
309
|
+
message: "Internal Server Error",
|
|
310
|
+
internal: {
|
|
311
|
+
operation,
|
|
312
|
+
upstreamStatus: status,
|
|
313
|
+
...internal
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Reads a single option value directly from the Options API.
|
|
319
|
+
* Returns `undefined` when the option is not set (404).
|
|
320
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
321
|
+
* @param category - The settings category
|
|
322
|
+
* @param key - The tenant option key
|
|
323
|
+
*/
|
|
324
|
+
async function fetchOptionValue(client, category, key) {
|
|
325
|
+
const endpoint = optionsPath(category, key);
|
|
326
|
+
const res = await client.core.fetch(endpoint, {
|
|
327
|
+
method: "GET",
|
|
328
|
+
headers: { Accept: "application/json" }
|
|
329
|
+
});
|
|
330
|
+
if (res.status === 404) return;
|
|
331
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to read tenant option '${key}'`, {
|
|
332
|
+
method: "GET",
|
|
333
|
+
endpoint,
|
|
334
|
+
requiredRole: OPTION_READ_ROLE
|
|
335
|
+
});
|
|
336
|
+
return (await res.json())?.value;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Creates or updates a single option value (upsert) via the Options API.
|
|
340
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
341
|
+
* @param category - The settings category
|
|
342
|
+
* @param key - The tenant option key
|
|
343
|
+
* @param value - The value to store
|
|
344
|
+
*/
|
|
345
|
+
async function putOptionValue(client, category, key, value) {
|
|
346
|
+
const endpoint = optionsPath(category, key);
|
|
347
|
+
const res = await client.core.fetch(endpoint, {
|
|
348
|
+
method: "PUT",
|
|
349
|
+
headers: {
|
|
350
|
+
"Content-Type": "application/json",
|
|
351
|
+
"Accept": "application/json"
|
|
352
|
+
},
|
|
353
|
+
body: JSON.stringify({ value })
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to set tenant option '${key}'`, {
|
|
356
|
+
method: "PUT",
|
|
357
|
+
endpoint,
|
|
358
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Deletes a single option via the Options API. Idempotent: a 404 resolves.
|
|
363
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
364
|
+
* @param category - The settings category
|
|
365
|
+
* @param key - The tenant option key
|
|
366
|
+
*/
|
|
367
|
+
async function deleteOptionValue(client, category, key) {
|
|
368
|
+
const endpoint = optionsPath(category, key);
|
|
369
|
+
const res = await client.core.fetch(endpoint, {
|
|
370
|
+
method: "DELETE",
|
|
371
|
+
headers: { Accept: "application/json" }
|
|
372
|
+
});
|
|
373
|
+
if (res.status === 404) return;
|
|
374
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to delete tenant option '${key}'`, {
|
|
375
|
+
method: "DELETE",
|
|
376
|
+
endpoint,
|
|
377
|
+
requiredRole: OPTION_ADMIN_ROLE
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Updates the `editable` flag of a single option (management tenant only).
|
|
382
|
+
* @param client - The Cumulocity client (must be the management tenant)
|
|
383
|
+
* @param category - The settings category
|
|
384
|
+
* @param key - The tenant option key
|
|
385
|
+
* @param editable - Whether the option may be edited
|
|
386
|
+
* @param targetTenant - The tenant whose option flag is updated
|
|
387
|
+
*/
|
|
388
|
+
async function putOptionEditable(client, category, key, editable, targetTenant) {
|
|
389
|
+
const endpoint = `${optionsPath(category, key)}/editable`;
|
|
390
|
+
const res = await client.core.fetch(`${endpoint}?targetTenant=${encodeURIComponent(targetTenant)}`, {
|
|
391
|
+
method: "PUT",
|
|
392
|
+
headers: {
|
|
393
|
+
"Content-Type": "application/json",
|
|
394
|
+
"Accept": "application/json"
|
|
395
|
+
},
|
|
396
|
+
body: JSON.stringify({ editable })
|
|
397
|
+
});
|
|
398
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to update editable flag for tenant option '${key}'`, {
|
|
399
|
+
method: "PUT",
|
|
400
|
+
endpoint,
|
|
401
|
+
targetTenant,
|
|
402
|
+
requiredRole: `${OPTION_ADMIN_ROLE} (management tenant only)`
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Lists all options set in a category as a key/value map.
|
|
407
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
408
|
+
* @param category - The settings category
|
|
409
|
+
*/
|
|
410
|
+
async function listCategoryOptions(client, category) {
|
|
411
|
+
const endpoint = optionsPath(category);
|
|
412
|
+
const res = await client.core.fetch(endpoint, {
|
|
413
|
+
method: "GET",
|
|
414
|
+
headers: { Accept: "application/json" }
|
|
415
|
+
});
|
|
416
|
+
if (res.status === 404) return {};
|
|
417
|
+
if (!res.ok) throw optionRequestError(res.status, `Failed to list tenant options for category '${category}'`, {
|
|
418
|
+
method: "GET",
|
|
419
|
+
endpoint,
|
|
420
|
+
requiredRole: OPTION_READ_ROLE
|
|
421
|
+
});
|
|
422
|
+
return { ...await res.json() };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Gets the cache TTL for a specific tenant option key.
|
|
426
|
+
* Uses per-key override if defined, otherwise falls back to the default TTL.
|
|
427
|
+
* @param key - The tenant option key
|
|
428
|
+
*/
|
|
429
|
+
function getTenantOptionCacheTTL(key) {
|
|
430
|
+
const config = useRuntimeConfig();
|
|
431
|
+
return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Factory that creates a cached read fetcher for a specific tenant/category/key.
|
|
435
|
+
* All fetchers share the same cache group so they can be invalidated together.
|
|
436
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
437
|
+
* @param tenant - The tenant id (for the cache key)
|
|
438
|
+
* @param category - The settings category
|
|
439
|
+
* @param key - The tenant option key
|
|
440
|
+
*/
|
|
441
|
+
function createCachedTenantOptionFetcher(client, tenant, category, key) {
|
|
442
|
+
const cacheName = `_c8y_nitro_tenant_option_${fetcherStoreKey(tenant, category, key).replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
443
|
+
const cachedFetcher = defineCachedFunction(async () => fetchOptionValue(client, category, key), {
|
|
444
|
+
maxAge: getTenantOptionCacheTTL(key),
|
|
445
|
+
name: cacheName,
|
|
446
|
+
group: "c8y_nitro",
|
|
447
|
+
swr: false
|
|
448
|
+
});
|
|
449
|
+
return Object.assign(cachedFetcher, { refresh: async () => {
|
|
450
|
+
await cachedFetcher.invalidate();
|
|
451
|
+
return await cachedFetcher();
|
|
452
|
+
} });
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Gets or creates the cached read fetcher for a tenant/category/key triple.
|
|
456
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
457
|
+
* @param tenant - The resolved tenant id
|
|
458
|
+
* @param category - The settings category
|
|
459
|
+
* @param key - The tenant option key
|
|
460
|
+
*/
|
|
461
|
+
function getOrCreateFetcher(client, tenant, category, key) {
|
|
462
|
+
const storeKey = fetcherStoreKey(tenant, category, key);
|
|
463
|
+
let fetcher = tenantOptionFetchers[storeKey];
|
|
464
|
+
if (!fetcher) {
|
|
465
|
+
fetcher = createCachedTenantOptionFetcher(client, tenant, category, key);
|
|
466
|
+
tenantOptionFetchers[storeKey] = fetcher;
|
|
467
|
+
}
|
|
468
|
+
return fetcher;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Invalidates the cached read for a store key, if one has been created.
|
|
472
|
+
* @param storeKey - The composite `${tenant}::${category}::${key}` store key
|
|
473
|
+
*/
|
|
474
|
+
async function invalidateStoreKey(storeKey) {
|
|
475
|
+
const fetcher = tenantOptionFetchers[storeKey];
|
|
476
|
+
if (fetcher) await fetcher.invalidate();
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Invalidates every created fetcher whose store key matches the predicate.
|
|
480
|
+
* @param predicate - Selects store keys to invalidate
|
|
481
|
+
*/
|
|
482
|
+
async function invalidateFetchers(predicate) {
|
|
483
|
+
await Promise.all(Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey)).map(([, fetcher]) => fetcher.invalidate()));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Refreshes every created fetcher whose store key matches the predicate.
|
|
487
|
+
* @param predicate - Selects store keys to refresh
|
|
488
|
+
* @returns Map of matched store keys to their refreshed values
|
|
489
|
+
*/
|
|
490
|
+
async function refreshFetchers(predicate) {
|
|
491
|
+
const entries = Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey));
|
|
492
|
+
const values = await Promise.all(entries.map(([, fetcher]) => fetcher.refresh()));
|
|
493
|
+
return Object.fromEntries(entries.map(([storeKey], i) => [storeKey, values[i]]));
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Builds a single-option handle for a client/category/key.
|
|
497
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
498
|
+
* @param category - The settings category
|
|
499
|
+
* @param key - The tenant option key
|
|
500
|
+
*/
|
|
501
|
+
function makeTenantOption(client, category, key) {
|
|
502
|
+
const currentStoreKey = async () => fetcherStoreKey(await resolveClientTenant(client), category, key);
|
|
503
|
+
const readFetcher = async () => getOrCreateFetcher(client, await resolveClientTenant(client), category, key);
|
|
504
|
+
return {
|
|
505
|
+
read: async () => (await readFetcher())(),
|
|
506
|
+
set: async (value) => {
|
|
507
|
+
await putOptionValue(client, category, key, value);
|
|
508
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
509
|
+
},
|
|
510
|
+
getOrInsert: async (fallback) => {
|
|
511
|
+
const current = await (await readFetcher())();
|
|
512
|
+
if (current !== void 0) return current;
|
|
513
|
+
await putOptionValue(client, category, key, fallback);
|
|
514
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
515
|
+
return fallback;
|
|
516
|
+
},
|
|
517
|
+
delete: async () => {
|
|
518
|
+
await deleteOptionValue(client, category, key);
|
|
519
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
520
|
+
},
|
|
521
|
+
setEditable: async (editable, options) => {
|
|
522
|
+
await putOptionEditable(client, category, key, editable, options.targetTenant);
|
|
523
|
+
await invalidateStoreKey(await currentStoreKey());
|
|
524
|
+
},
|
|
525
|
+
refresh: async () => (await readFetcher()).refresh(),
|
|
526
|
+
invalidate: async () => invalidateStoreKey(await currentStoreKey())
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Builds a category handle. The runtime shape is identical for the own and
|
|
531
|
+
* foreign categories; the public overloads differ only at the type level.
|
|
532
|
+
* @param client - The Cumulocity client (determines tenant + auth)
|
|
533
|
+
* @param category - The settings category
|
|
534
|
+
*/
|
|
535
|
+
function makeTenantOptionCategory(client, category) {
|
|
536
|
+
const categoryPrefix = async () => `${await resolveClientTenant(client)}::${category}::`;
|
|
537
|
+
return {
|
|
538
|
+
option: (key) => makeTenantOption(client, category, key),
|
|
539
|
+
list: () => listCategoryOptions(client, category),
|
|
540
|
+
setAll: async (values) => {
|
|
541
|
+
const entries = Object.entries(values);
|
|
542
|
+
await Promise.all(entries.map(([key, value]) => putOptionValue(client, category, key, value)));
|
|
543
|
+
const tenant = await resolveClientTenant(client);
|
|
544
|
+
await Promise.all(entries.map(([key]) => invalidateStoreKey(fetcherStoreKey(tenant, category, key))));
|
|
545
|
+
},
|
|
546
|
+
invalidateAll: async () => {
|
|
547
|
+
const prefix = await categoryPrefix();
|
|
548
|
+
await invalidateFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
549
|
+
},
|
|
550
|
+
refreshAll: async () => {
|
|
551
|
+
const prefix = await categoryPrefix();
|
|
552
|
+
const refreshed = await refreshFetchers((storeKey) => storeKey.startsWith(prefix));
|
|
553
|
+
return Object.fromEntries(Object.entries(refreshed).map(([storeKey, value]) => [storeKey.slice(prefix.length), value]));
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Gets a handle for a single tenant option in the microservice's **own** settings
|
|
559
|
+
* category, targeting the tenant of the given `client`.\
|
|
560
|
+
* Reads are cached per tenant, using the client's service user credentials.
|
|
561
|
+
*
|
|
562
|
+
* This is a shortcut for `useTenantOptions(client).option(key)` — it only drops
|
|
563
|
+
* the category argument. For a single option in a **foreign** category, use
|
|
564
|
+
* `useTenantOptions(client, category).option(key)`.
|
|
565
|
+
*
|
|
566
|
+
* Manifest-declared keys are offered via autocomplete, but any dynamic string
|
|
567
|
+
* is accepted too (e.g. `encrypted.password.${hash}`).
|
|
568
|
+
*
|
|
569
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
570
|
+
* @param key - The tenant option key
|
|
571
|
+
*
|
|
572
|
+
* @config Cache TTL can be configured via:
|
|
573
|
+
* - `c8y.cache.defaultTenantOptionsTTL` — Default TTL for all keys (in seconds)
|
|
574
|
+
* - `c8y.cache.tenantOptions` — Per-key TTL overrides
|
|
575
|
+
* - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* // Read the owner tenant's config:
|
|
579
|
+
* const value = await useTenantOption(await useDeployedTenantClient(), 'featureMode').read()
|
|
580
|
+
*
|
|
581
|
+
* // Read the current request tenant's config (multi-tenant):
|
|
582
|
+
* const value = await useTenantOption(await useUserTenantClient(event), 'featureMode').read()
|
|
583
|
+
*
|
|
584
|
+
* // Create or update, or seed a dynamic key:
|
|
585
|
+
* await useTenantOption(client, 'featureMode').set('advanced')
|
|
586
|
+
* const token = await useTenantOption(client, `encrypted.password.${hash}`).getOrInsert('')
|
|
587
|
+
*/
|
|
588
|
+
function useTenantOption(client, key) {
|
|
589
|
+
return makeTenantOption(client, getOwnCategory(), key);
|
|
590
|
+
}
|
|
591
|
+
const useTenantOptionsBase = ((client, category) => makeTenantOptionCategory(client, category ?? getOwnCategory()));
|
|
592
|
+
/**
|
|
593
|
+
* Gets a handle for a whole settings category, targeting the tenant of the given
|
|
594
|
+
* `client`.\
|
|
595
|
+
* Omit the category (or pass the own {@link C8YSettingsCategory}) for the
|
|
596
|
+
* microservice's own category; pass any other category to read/write options in
|
|
597
|
+
* a foreign category (where `credentials.*` keys are rejected).
|
|
598
|
+
*
|
|
599
|
+
* @param client - The Cumulocity client that determines the target tenant
|
|
600
|
+
*
|
|
601
|
+
* @example
|
|
602
|
+
* const client = await useUserTenantClient(event)
|
|
603
|
+
*
|
|
604
|
+
* // Own category:
|
|
605
|
+
* const values = await useTenantOptions(client).list()
|
|
606
|
+
* await useTenantOptions(client).option('featureMode').set('advanced')
|
|
607
|
+
*
|
|
608
|
+
* // Foreign category:
|
|
609
|
+
* const value = await useTenantOptions(client, 'other-service').option('someKey').read()
|
|
610
|
+
*
|
|
611
|
+
* // Invalidate all cached options of one tenant + category:
|
|
612
|
+
* await useTenantOptions(client, 'other-service').invalidateAll()
|
|
613
|
+
*
|
|
614
|
+
* // Invalidate everything, across all tenants and categories:
|
|
615
|
+
* await useTenantOptions.invalidateAll()
|
|
616
|
+
*/
|
|
617
|
+
const useTenantOptions = Object.assign(useTenantOptionsBase, {
|
|
618
|
+
invalidateAll: () => invalidateFetchers(() => true),
|
|
619
|
+
refreshAll: () => refreshFetchers(() => true)
|
|
620
|
+
});
|
|
9
621
|
//#endregion
|
|
10
622
|
//#region src/module/runtime/handlers/invalidateTenantOptions.ts
|
|
11
623
|
var invalidateTenantOptions_default = defineEventHandler(async (event) => {
|
|
12
624
|
const query = getQuery(event);
|
|
13
625
|
if ("all" in query) {
|
|
14
|
-
await
|
|
626
|
+
await useTenantOptions.invalidateAll();
|
|
15
627
|
return { message: "success" };
|
|
16
628
|
}
|
|
17
629
|
const key = Array.isArray(query.key) ? query.key[0] : query.key;
|
|
@@ -27,8 +639,7 @@ var invalidateTenantOptions_default = defineEventHandler(async (event) => {
|
|
|
27
639
|
why: "Only tenant option keys declared in manifest.settings can be invalidated via this route",
|
|
28
640
|
fix: "Use ?all to invalidate all created tenant option caches or provide a valid manifest-defined key in ?key"
|
|
29
641
|
});
|
|
30
|
-
|
|
31
|
-
if (fetcher) await fetcher.invalidate();
|
|
642
|
+
await useTenantOption(await useDeployedTenantClient(), key).invalidate();
|
|
32
643
|
return { message: "success" };
|
|
33
644
|
});
|
|
34
645
|
//#endregion
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { t as createError } from "../../logging-
|
|
2
|
-
import { defineMiddleware } from "nitro";
|
|
1
|
+
import { t as createError } from "../../logging-DYnYICoX.mjs";
|
|
3
2
|
import process from "node:process";
|
|
3
|
+
import { defineMiddleware } from "nitro";
|
|
4
4
|
//#region src/module/runtime/middlewares/c8y-client.ts
|
|
5
5
|
function isC8yClientThrownResponse(error) {
|
|
6
6
|
if (!error || typeof error !== "object") return false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { defineHandler } from "nitro/h3";
|
|
2
|
-
import process from "node:process";
|
|
3
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { defineHandler } from "nitro/h3";
|
|
4
4
|
import consola from "consola";
|
|
5
5
|
//#region src/module/runtime/middlewares/dev-user.dev.ts
|
|
6
6
|
var dev_user_dev_default = defineHandler((event) => {
|
package/dist/types.d.mts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
|
|
1
|
+
import { a as C8YTenantOptionKeysCacheConfig, c as C8YZipOptions, d as C8YAPIClientOptions, i as C8YTenantOptionKey, l as C8YManifest, n as C8yNitroModuleOptions, o as C8YRoles, r as C8YSettingsCategory, s as C8yCacheOptions, t as C8yDevOptions, u as C8YManifestOptions } from "./index-ByDSMEvU.mjs";
|
|
2
|
+
import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
|
|
3
|
+
export { C8YAPIClientOptions, type C8YManifest, type C8YManifestOptions, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, C8yNitroModuleOptions, type TenantCredentials };
|