c8y-nitro 0.6.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/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
4
  import { useLogger } from "evlog/nitro/v3";
4
5
  import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
5
6
  import { defineCachedFunction } from "nitro/cache";
6
- import { createHash, randomBytes, randomUUID } from "node:crypto";
7
- import { HTTPError, defineHandler } from "nitro/h3";
7
+ import { createError, 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
@@ -151,7 +151,7 @@ const useSubscribedTenantCredentials = Object.assign(async () => {
151
151
  * Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
152
152
  * Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
153
153
  * @returns Credentials for the deployed tenant
154
- * @throws {HTTPError} If no credentials found for the deployed tenant
154
+ * @throws {EvlogError} If no credentials found for the deployed tenant
155
155
  * @example
156
156
  * // Get deployed tenant credentials:
157
157
  * const creds = await useDeployedTenantCredentials()
@@ -167,10 +167,9 @@ const useSubscribedTenantCredentials = Object.assign(async () => {
167
167
  const useDeployedTenantCredentials = Object.assign(async () => {
168
168
  const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
169
169
  const allCredsPromise = await useSubscribedTenantCredentials();
170
- if (!allCredsPromise[tenant]) throw new HTTPError({
170
+ if (!allCredsPromise[tenant]) throw createError({
171
171
  message: `No credentials found for tenant deployed tenant '${tenant}'`,
172
- status: 500,
173
- statusText: "Internal Server Error"
172
+ status: 500
174
173
  });
175
174
  return allCredsPromise[tenant];
176
175
  }, {
@@ -186,7 +185,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
186
185
  * Results are cached in the request context for subsequent calls within the same request.
187
186
  * @param requestOrEvent - The H3Event or ServerRequest from the current request
188
187
  * @returns Credentials for the user's tenant
189
- * @throws {HTTPError} If no subscribed tenant credentials found for the user's tenant
188
+ * @throws {EvlogError} If no subscribed tenant credentials found for the user's tenant
190
189
  * @example
191
190
  * // In a request handler:
192
191
  * const userCreds = await useUserTenantCredentials(event)
@@ -200,10 +199,9 @@ async function useUserTenantCredentials(requestOrEvent) {
200
199
  if (request.context?.["c8y_user_tenant_credentials"]) return request.context["c8y_user_tenant_credentials"];
201
200
  const tenantId = await getCurrentUserTenantId(requestOrEvent);
202
201
  const userTenantCreds = (await useSubscribedTenantCredentials())[tenantId];
203
- if (!userTenantCreds) throw new HTTPError({
202
+ if (!userTenantCreds) throw createError({
204
203
  message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
205
- status: 500,
206
- statusText: "Internal Server Error"
204
+ status: 500
207
205
  });
208
206
  request.context ??= {};
209
207
  request.context["c8y_user_tenant_credentials"] = userTenantCreds;
@@ -244,12 +242,12 @@ async function useUserTenantClient(requestOrEvent) {
244
242
  if (request.context?.["c8y_user_tenant_client"]) return request.context["c8y_user_tenant_client"];
245
243
  const tenantId = await getCurrentUserTenantId(requestOrEvent);
246
244
  const creds = await useSubscribedTenantCredentials();
247
- if (!creds[tenantId]) throw new HTTPError({
245
+ if (!creds[tenantId]) throw createError({
248
246
  message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
249
- status: 500,
250
- statusText: "Internal Server Error"
247
+ status: 500
251
248
  });
252
249
  const tenantClient = new Client(new BasicAuth(creds[tenantId]), process.env.C8Y_BASEURL);
250
+ tenantClient.core.tenant = tenantId;
253
251
  request.context ??= {};
254
252
  request.context["c8y_user_tenant_client"] = tenantClient;
255
253
  return tenantClient;
@@ -269,7 +267,11 @@ async function useUserTenantClient(requestOrEvent) {
269
267
  async function useSubscribedTenantClients() {
270
268
  const creds = await useSubscribedTenantCredentials();
271
269
  const clients = {};
272
- for (const [tenant, tenantCreds] of Object.entries(creds)) clients[tenant] = new Client(new BasicAuth(tenantCreds), process.env.C8Y_BASEURL);
270
+ for (const [tenant, tenantCreds] of Object.entries(creds)) {
271
+ const client = new Client(new BasicAuth(tenantCreds), process.env.C8Y_BASEURL);
272
+ client.core.tenant = tenant;
273
+ clients[tenant] = client;
274
+ }
273
275
  return clients;
274
276
  }
275
277
  /**
@@ -284,12 +286,13 @@ async function useSubscribedTenantClients() {
284
286
  async function useDeployedTenantClient() {
285
287
  const creds = await useSubscribedTenantCredentials();
286
288
  const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
287
- if (!creds[tenant]) throw new HTTPError({
289
+ if (!creds[tenant]) throw createError({
288
290
  message: `No subscribed tenant credentials found for tenant '${tenant}'`,
289
- status: 500,
290
- statusText: "Internal Server Error"
291
+ status: 500
291
292
  });
292
- return new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
293
+ const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
294
+ client.core.tenant = tenant;
295
+ return client;
293
296
  }
294
297
  //#endregion
295
298
  //#region src/utils/resources.ts
@@ -307,10 +310,9 @@ async function useUser(requestOrEvent) {
307
310
  const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
308
311
  if (request.context?.["c8y_user"]) return request.context["c8y_user"];
309
312
  const { res, data: user } = await useUserClient(requestOrEvent).user.currentWithEffectiveRoles();
310
- if (!res.ok) throw new HTTPError({
313
+ if (!res.ok) throw createError({
311
314
  message: `Failed to fetch current user`,
312
- status: res.status,
313
- statusText: res.statusText
315
+ status: res.status
314
316
  });
315
317
  request.context ??= {};
316
318
  request.context["c8y_user"] = user;
@@ -346,9 +348,8 @@ function hasUserRequiredRole(roleOrRoles) {
346
348
  if (isProbeRequest(event.url.pathname)) return;
347
349
  const requiredRoles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
348
350
  const userRoles = await useUserRoles(event);
349
- if (!requiredRoles.some((role) => userRoles.includes(role))) throw new HTTPError({
351
+ if (!requiredRoles.some((role) => userRoles.includes(role))) throw createError({
350
352
  status: 403,
351
- statusText: "Forbidden",
352
353
  message: `User does not have required role(s) to access this resource: ${requiredRoles.join(", ")}`
353
354
  });
354
355
  });
@@ -358,9 +359,8 @@ function isUserFromAllowedTenant(tenantIdOrIds) {
358
359
  if (isProbeRequest(event.url.pathname)) return;
359
360
  const allowedTenants = Array.isArray(tenantIdOrIds) ? tenantIdOrIds : [tenantIdOrIds];
360
361
  const userTenantId = await getCurrentUserTenantId(event);
361
- if (!allowedTenants.includes(userTenantId)) throw new HTTPError({
362
+ if (!allowedTenants.includes(userTenantId)) throw createError({
362
363
  status: 403,
363
- statusText: "Forbidden",
364
364
  message: `User's tenant '${userTenantId}' is not allowed to access this resource. Allowed tenants: ${allowedTenants.join(", ")}`
365
365
  });
366
366
  });
@@ -385,14 +385,9 @@ function isUserFromDeployedTenant() {
385
385
  if (isProbeRequest(event.url.pathname)) return;
386
386
  const userTenantId = await getCurrentUserTenantId(event);
387
387
  const deployedTenantId = process.env.C8Y_BOOTSTRAP_TENANT;
388
- if (!deployedTenantId) throw new HTTPError({
389
- status: 500,
390
- statusText: "Internal Server Error",
391
- message: "C8Y_BOOTSTRAP_TENANT environment variable is not set"
392
- });
393
- if (userTenantId !== deployedTenantId) throw new HTTPError({
388
+ if (!deployedTenantId) throw new Error("C8Y_BOOTSTRAP_TENANT environment variable is not set");
389
+ if (userTenantId !== deployedTenantId) throw createError({
394
390
  status: 403,
395
- statusText: "Forbidden",
396
391
  message: `Only users from tenant '${deployedTenantId}' can access this resource.`
397
392
  });
398
393
  });
@@ -400,14 +395,211 @@ function isUserFromDeployedTenant() {
400
395
  //#endregion
401
396
  //#region src/utils/internal/tenantOptionFetchers.ts
402
397
  /**
403
- * Internal storage for cached functions per key
398
+ * Internal storage for cached option fetchers, keyed by
399
+ * `${tenant}::${category}::${key}`.
400
+ *
401
+ * Including the tenant and category in the key keeps options from different
402
+ * tenants (multi-tenancy) and settings categories from colliding on a shared
403
+ * `key`.
404
404
  */
405
405
  const tenantOptionFetchers = {};
406
+ /**
407
+ * Builds the composite store key for a tenant/category/key triple.
408
+ * @param tenant - The tenant id owning the option
409
+ * @param category - The settings category
410
+ * @param key - The tenant option key
411
+ */
412
+ function fetcherStoreKey(tenant, category, key) {
413
+ return `${tenant}::${category}::${key}`;
414
+ }
406
415
  //#endregion
407
416
  //#region src/utils/tenantOptions.ts
408
417
  /**
418
+ * The microservice's own settings category, resolved once from runtime config
419
+ * (`manifest.settingsCategory` → `contextPath` → `name`). This is independent of
420
+ * which tenant is targeted.
421
+ */
422
+ function getOwnCategory() {
423
+ return useRuntimeConfig().c8ySettingsCategory;
424
+ }
425
+ /**
426
+ * Resolves the tenant id a client operates on, used for per-tenant cache keys.
427
+ *
428
+ * Prefers the stamped `client.core.tenant` (set by the `use*Client` helpers);
429
+ * otherwise resolves it on demand via `/tenant/currentTenant` and memoizes it
430
+ * back onto the client.
431
+ * @param client - The Cumulocity client
432
+ */
433
+ async function resolveClientTenant(client) {
434
+ if (client.core.tenant) return client.core.tenant;
435
+ const res = await client.core.fetch("/tenant/currentTenant", {
436
+ method: "GET",
437
+ headers: { Accept: "application/json" }
438
+ });
439
+ const tenant = res.ok ? (await res.json())?.name : void 0;
440
+ if (!tenant) throw createError({
441
+ status: 500,
442
+ message: "Could not determine the target tenant",
443
+ fix: "This is unexpected. Retry the request, and contact the microservice operator if it keeps happening.",
444
+ internal: {
445
+ reason: res.ok ? "GET /tenant/currentTenant returned no tenant name" : "GET /tenant/currentTenant failed",
446
+ upstreamStatus: res.status
447
+ }
448
+ });
449
+ client.core.tenant = tenant;
450
+ return tenant;
451
+ }
452
+ /**
453
+ * Builds the Cumulocity Options API path for a category (and optional key).
454
+ * @param category - The settings category
455
+ * @param key - The tenant option key (omit for category-level operations)
456
+ */
457
+ function optionsPath(category, key) {
458
+ const base = `/tenant/options/${encodeURIComponent(category)}`;
459
+ return key === void 0 ? base : `${base}/${encodeURIComponent(key)}`;
460
+ }
461
+ /**
462
+ * Cumulocity role required to read tenant options.
463
+ */
464
+ const OPTION_READ_ROLE = "ROLE_OPTION_MANAGEMENT_READ";
465
+ /**
466
+ * Cumulocity role required to create, update or delete tenant options.
467
+ */
468
+ const OPTION_ADMIN_ROLE = "ROLE_OPTION_MANAGEMENT_ADMIN";
469
+ /**
470
+ * Builds a structured error for a failed Options API request.
471
+ *
472
+ * A failed options request is a microservice/config problem (e.g. a missing
473
+ * `ROLE_OPTION_MANAGEMENT_*` role), not something the frontend caller can act on
474
+ * — so the response is a **generic 500** that reveals nothing about tenant
475
+ * options. All the diagnostic detail (which operation, upstream status, internal
476
+ * endpoint/method, and the Cumulocity role it needs) goes into `internal`, which
477
+ * is logged for debugging but never serialized to the HTTP response.
478
+ * @param status - The upstream HTTP status (for `internal`)
479
+ * @param operation - A human-readable summary of the failed operation (for `internal`)
480
+ * @param internal - Backend-only debugging context (endpoint, method, required role)
481
+ */
482
+ function optionRequestError(status, operation, internal) {
483
+ return createError({
484
+ status: 500,
485
+ message: "Internal Server Error",
486
+ internal: {
487
+ operation,
488
+ upstreamStatus: status,
489
+ ...internal
490
+ }
491
+ });
492
+ }
493
+ /**
494
+ * Reads a single option value directly from the Options API.
495
+ * Returns `undefined` when the option is not set (404).
496
+ * @param client - The Cumulocity client (determines tenant + auth)
497
+ * @param category - The settings category
498
+ * @param key - The tenant option key
499
+ */
500
+ async function fetchOptionValue(client, category, key) {
501
+ const endpoint = optionsPath(category, key);
502
+ const res = await client.core.fetch(endpoint, {
503
+ method: "GET",
504
+ headers: { Accept: "application/json" }
505
+ });
506
+ if (res.status === 404) return;
507
+ if (!res.ok) throw optionRequestError(res.status, `Failed to read tenant option '${key}'`, {
508
+ method: "GET",
509
+ endpoint,
510
+ requiredRole: OPTION_READ_ROLE
511
+ });
512
+ return (await res.json())?.value;
513
+ }
514
+ /**
515
+ * Creates or updates a single option value (upsert) via the Options API.
516
+ * @param client - The Cumulocity client (determines tenant + auth)
517
+ * @param category - The settings category
518
+ * @param key - The tenant option key
519
+ * @param value - The value to store
520
+ */
521
+ async function putOptionValue(client, category, key, value) {
522
+ const endpoint = optionsPath(category, key);
523
+ const res = await client.core.fetch(endpoint, {
524
+ method: "PUT",
525
+ headers: {
526
+ "Content-Type": "application/json",
527
+ "Accept": "application/json"
528
+ },
529
+ body: JSON.stringify({ value })
530
+ });
531
+ if (!res.ok) throw optionRequestError(res.status, `Failed to set tenant option '${key}'`, {
532
+ method: "PUT",
533
+ endpoint,
534
+ requiredRole: OPTION_ADMIN_ROLE
535
+ });
536
+ }
537
+ /**
538
+ * Deletes a single option via the Options API. Idempotent: a 404 resolves.
539
+ * @param client - The Cumulocity client (determines tenant + auth)
540
+ * @param category - The settings category
541
+ * @param key - The tenant option key
542
+ */
543
+ async function deleteOptionValue(client, category, key) {
544
+ const endpoint = optionsPath(category, key);
545
+ const res = await client.core.fetch(endpoint, {
546
+ method: "DELETE",
547
+ headers: { Accept: "application/json" }
548
+ });
549
+ if (res.status === 404) return;
550
+ if (!res.ok) throw optionRequestError(res.status, `Failed to delete tenant option '${key}'`, {
551
+ method: "DELETE",
552
+ endpoint,
553
+ requiredRole: OPTION_ADMIN_ROLE
554
+ });
555
+ }
556
+ /**
557
+ * Updates the `editable` flag of a single option (management tenant only).
558
+ * @param client - The Cumulocity client (must be the management tenant)
559
+ * @param category - The settings category
560
+ * @param key - The tenant option key
561
+ * @param editable - Whether the option may be edited
562
+ * @param targetTenant - The tenant whose option flag is updated
563
+ */
564
+ async function putOptionEditable(client, category, key, editable, targetTenant) {
565
+ const endpoint = `${optionsPath(category, key)}/editable`;
566
+ const res = await client.core.fetch(`${endpoint}?targetTenant=${encodeURIComponent(targetTenant)}`, {
567
+ method: "PUT",
568
+ headers: {
569
+ "Content-Type": "application/json",
570
+ "Accept": "application/json"
571
+ },
572
+ body: JSON.stringify({ editable })
573
+ });
574
+ if (!res.ok) throw optionRequestError(res.status, `Failed to update editable flag for tenant option '${key}'`, {
575
+ method: "PUT",
576
+ endpoint,
577
+ targetTenant,
578
+ requiredRole: `${OPTION_ADMIN_ROLE} (management tenant only)`
579
+ });
580
+ }
581
+ /**
582
+ * Lists all options set in a category as a key/value map.
583
+ * @param client - The Cumulocity client (determines tenant + auth)
584
+ * @param category - The settings category
585
+ */
586
+ async function listCategoryOptions(client, category) {
587
+ const endpoint = optionsPath(category);
588
+ const res = await client.core.fetch(endpoint, {
589
+ method: "GET",
590
+ headers: { Accept: "application/json" }
591
+ });
592
+ if (res.status === 404) return {};
593
+ if (!res.ok) throw optionRequestError(res.status, `Failed to list tenant options for category '${category}'`, {
594
+ method: "GET",
595
+ endpoint,
596
+ requiredRole: OPTION_READ_ROLE
597
+ });
598
+ return { ...await res.json() };
599
+ }
600
+ /**
409
601
  * Gets the cache TTL for a specific tenant option key.
410
- * Uses per-key override if defined, otherwise falls back to default TTL.
602
+ * Uses per-key override if defined, otherwise falls back to the default TTL.
411
603
  * @param key - The tenant option key
412
604
  */
413
605
  function getTenantOptionCacheTTL(key) {
@@ -415,24 +607,16 @@ function getTenantOptionCacheTTL(key) {
415
607
  return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
416
608
  }
417
609
  /**
418
- * Factory function that creates a cached fetcher for a specific tenant option key.
610
+ * Factory that creates a cached read fetcher for a specific tenant/category/key.
611
+ * All fetchers share the same cache group so they can be invalidated together.
612
+ * @param client - The Cumulocity client (determines tenant + auth)
613
+ * @param tenant - The tenant id (for the cache key)
614
+ * @param category - The settings category
419
615
  * @param key - The tenant option key
420
616
  */
421
- function createCachedTenantOptionFetcher(key) {
422
- const cacheName = `_c8y_nitro_tenant_option_${key.replace(/\./g, "_")}`;
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
- }, {
617
+ function createCachedTenantOptionFetcher(client, tenant, category, key) {
618
+ const cacheName = `_c8y_nitro_tenant_option_${fetcherStoreKey(tenant, category, key).replace(/[^a-zA-Z0-9]/g, "_")}`;
619
+ const cachedFetcher = defineCachedFunction(async () => fetchOptionValue(client, category, key), {
436
620
  maxAge: getTenantOptionCacheTTL(key),
437
621
  name: cacheName,
438
622
  group: "c8y_nitro",
@@ -444,24 +628,122 @@ function createCachedTenantOptionFetcher(key) {
444
628
  } });
445
629
  }
446
630
  /**
447
- * Gets or creates a cached fetcher for a specific tenant option key.
631
+ * Gets or creates the cached read fetcher for a tenant/category/key triple.
632
+ * @param client - The Cumulocity client (determines tenant + auth)
633
+ * @param tenant - The resolved tenant id
634
+ * @param category - The settings category
448
635
  * @param key - The tenant option key
449
636
  */
450
- function getOrCreateFetcher(key) {
451
- let fetcher = tenantOptionFetchers[key];
637
+ function getOrCreateFetcher(client, tenant, category, key) {
638
+ const storeKey = fetcherStoreKey(tenant, category, key);
639
+ let fetcher = tenantOptionFetchers[storeKey];
452
640
  if (!fetcher) {
453
- fetcher = createCachedTenantOptionFetcher(key);
454
- tenantOptionFetchers[key] = fetcher;
641
+ fetcher = createCachedTenantOptionFetcher(client, tenant, category, key);
642
+ tenantOptionFetchers[storeKey] = fetcher;
455
643
  }
456
644
  return fetcher;
457
645
  }
458
646
  /**
459
- * Fetches a tenant option value by key.\
460
- * Uses the deployed tenant's service user credentials to access the Options API.\
461
- * Results are cached based on the configured TTL (default: 10 minutes).
647
+ * Invalidates the cached read for a store key, if one has been created.
648
+ * @param storeKey - The composite `${tenant}::${category}::${key}` store key
649
+ */
650
+ async function invalidateStoreKey(storeKey) {
651
+ const fetcher = tenantOptionFetchers[storeKey];
652
+ if (fetcher) await fetcher.invalidate();
653
+ }
654
+ /**
655
+ * Invalidates every created fetcher whose store key matches the predicate.
656
+ * @param predicate - Selects store keys to invalidate
657
+ */
658
+ async function invalidateFetchers(predicate) {
659
+ await Promise.all(Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey)).map(([, fetcher]) => fetcher.invalidate()));
660
+ }
661
+ /**
662
+ * Refreshes every created fetcher whose store key matches the predicate.
663
+ * @param predicate - Selects store keys to refresh
664
+ * @returns Map of matched store keys to their refreshed values
665
+ */
666
+ async function refreshFetchers(predicate) {
667
+ const entries = Object.entries(tenantOptionFetchers).filter(([storeKey]) => predicate(storeKey));
668
+ const values = await Promise.all(entries.map(([, fetcher]) => fetcher.refresh()));
669
+ return Object.fromEntries(entries.map(([storeKey], i) => [storeKey, values[i]]));
670
+ }
671
+ /**
672
+ * Builds a single-option handle for a client/category/key.
673
+ * @param client - The Cumulocity client (determines tenant + auth)
674
+ * @param category - The settings category
675
+ * @param key - The tenant option key
676
+ */
677
+ function makeTenantOption(client, category, key) {
678
+ const currentStoreKey = async () => fetcherStoreKey(await resolveClientTenant(client), category, key);
679
+ const readFetcher = async () => getOrCreateFetcher(client, await resolveClientTenant(client), category, key);
680
+ return {
681
+ read: async () => (await readFetcher())(),
682
+ set: async (value) => {
683
+ await putOptionValue(client, category, key, value);
684
+ await invalidateStoreKey(await currentStoreKey());
685
+ },
686
+ getOrInsert: async (fallback) => {
687
+ const current = await (await readFetcher())();
688
+ if (current !== void 0) return current;
689
+ await putOptionValue(client, category, key, fallback);
690
+ await invalidateStoreKey(await currentStoreKey());
691
+ return fallback;
692
+ },
693
+ delete: async () => {
694
+ await deleteOptionValue(client, category, key);
695
+ await invalidateStoreKey(await currentStoreKey());
696
+ },
697
+ setEditable: async (editable, options) => {
698
+ await putOptionEditable(client, category, key, editable, options.targetTenant);
699
+ await invalidateStoreKey(await currentStoreKey());
700
+ },
701
+ refresh: async () => (await readFetcher()).refresh(),
702
+ invalidate: async () => invalidateStoreKey(await currentStoreKey())
703
+ };
704
+ }
705
+ /**
706
+ * Builds a category handle. The runtime shape is identical for the own and
707
+ * foreign categories; the public overloads differ only at the type level.
708
+ * @param client - The Cumulocity client (determines tenant + auth)
709
+ * @param category - The settings category
710
+ */
711
+ function makeTenantOptionCategory(client, category) {
712
+ const categoryPrefix = async () => `${await resolveClientTenant(client)}::${category}::`;
713
+ return {
714
+ option: (key) => makeTenantOption(client, category, key),
715
+ list: () => listCategoryOptions(client, category),
716
+ setAll: async (values) => {
717
+ const entries = Object.entries(values);
718
+ await Promise.all(entries.map(([key, value]) => putOptionValue(client, category, key, value)));
719
+ const tenant = await resolveClientTenant(client);
720
+ await Promise.all(entries.map(([key]) => invalidateStoreKey(fetcherStoreKey(tenant, category, key))));
721
+ },
722
+ invalidateAll: async () => {
723
+ const prefix = await categoryPrefix();
724
+ await invalidateFetchers((storeKey) => storeKey.startsWith(prefix));
725
+ },
726
+ refreshAll: async () => {
727
+ const prefix = await categoryPrefix();
728
+ const refreshed = await refreshFetchers((storeKey) => storeKey.startsWith(prefix));
729
+ return Object.fromEntries(Object.entries(refreshed).map(([storeKey, value]) => [storeKey.slice(prefix.length), value]));
730
+ }
731
+ };
732
+ }
733
+ /**
734
+ * Gets a handle for a single tenant option in the microservice's **own** settings
735
+ * category, targeting the tenant of the given `client`.\
736
+ * Reads are cached per tenant, using the client's service user credentials.
737
+ *
738
+ * This is a shortcut for `useTenantOptions(client).option(key)` — it only drops
739
+ * the category argument. For a single option in a **foreign** category, use
740
+ * `useTenantOptions(client, category).option(key)`.
462
741
  *
463
- * @param key - The tenant option key to fetch (as defined in `manifest.settings`)
464
- * @returns The option value as a string
742
+ * Manifest-declared keys are offered via autocomplete, but any dynamic string
743
+ * is accepted too (e.g. `encrypted.password.${hash}`).
744
+ *
745
+ * @param client - The Cumulocity client that determines the target tenant
746
+ * @param key - The tenant option key
465
747
  *
466
748
  * @config Cache TTL can be configured via:
467
749
  * - `c8y.cache.defaultTenantOptionsTTL` — Default TTL for all keys (in seconds)
@@ -469,59 +751,48 @@ function getOrCreateFetcher(key) {
469
751
  * - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
470
752
  *
471
753
  * @example
472
- * // Fetch a tenant option:
473
- * const value = await useTenantOption('myOption')
754
+ * // Read the owner tenant's config:
755
+ * const value = await useTenantOption(await useDeployedTenantClient(), 'featureMode').read()
474
756
  *
475
- * // Fetch an encrypted secret:
476
- * const secret = await useTenantOption('credentials.apiKey')
757
+ * // Read the current request tenant's config (multi-tenant):
758
+ * const value = await useTenantOption(await useUserTenantClient(event), 'featureMode').read()
477
759
  *
478
- * // Invalidate cache for a specific key:
479
- * await useTenantOption.invalidate('myOption')
760
+ * // Create or update, or seed a dynamic key:
761
+ * await useTenantOption(client, 'featureMode').set('advanced')
762
+ * const token = await useTenantOption(client, `encrypted.password.${hash}`).getOrInsert('')
763
+ */
764
+ function useTenantOption(client, key) {
765
+ return makeTenantOption(client, getOwnCategory(), key);
766
+ }
767
+ const useTenantOptionsBase = ((client, category) => makeTenantOptionCategory(client, category ?? getOwnCategory()));
768
+ /**
769
+ * Gets a handle for a whole settings category, targeting the tenant of the given
770
+ * `client`.\
771
+ * Omit the category (or pass the own {@link C8YSettingsCategory}) for the
772
+ * microservice's own category; pass any other category to read/write options in
773
+ * a foreign category (where `credentials.*` keys are rejected).
480
774
  *
481
- * // Force refresh a specific key:
482
- * const fresh = await useTenantOption.refresh('myOption')
775
+ * @param client - The Cumulocity client that determines the target tenant
483
776
  *
484
- * // Invalidate all tenant option caches:
485
- * await useTenantOption.invalidateAll()
777
+ * @example
778
+ * const client = await useUserTenantClient(event)
779
+ *
780
+ * // Own category:
781
+ * const values = await useTenantOptions(client).list()
782
+ * await useTenantOptions(client).option('featureMode').set('advanced')
783
+ *
784
+ * // Foreign category:
785
+ * const value = await useTenantOptions(client, 'other-service').option('someKey').read()
486
786
  *
487
- * // Refresh all tenant options:
488
- * const all = await useTenantOption.refreshAll()
787
+ * // Invalidate all cached options of one tenant + category:
788
+ * await useTenantOptions(client, 'other-service').invalidateAll()
789
+ *
790
+ * // Invalidate everything, across all tenants and categories:
791
+ * await useTenantOptions.invalidateAll()
489
792
  */
490
- const useTenantOption = Object.assign(async (key) => {
491
- return await getOrCreateFetcher(key)();
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
- }
793
+ const useTenantOptions = Object.assign(useTenantOptionsBase, {
794
+ invalidateAll: () => invalidateFetchers(() => true),
795
+ refreshAll: () => refreshFetchers(() => true)
525
796
  });
526
797
  //#endregion
527
798
  //#region src/utils/schedule.ts
@@ -688,4 +959,4 @@ async function cancelScheduledTask(id) {
688
959
  return cancelled;
689
960
  }
690
961
  //#endregion
691
- export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };
962
+ export { cancelScheduledTask, createError, createLogger, hasUserRequiredRole, isUserFromAllowedTenant, isUserFromDeployedTenant, listScheduledTasks, scheduleTask, useDeployedTenantClient, useDeployedTenantCredentials, useLogger, useSubscribedTenantClients, useSubscribedTenantCredentials, useTenantOption, useTenantOptions, useUser, useUserClient, useUserRoles, useUserTenantClient, useUserTenantCredentials };