c8y-nitro 0.4.1 → 0.5.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 CHANGED
@@ -143,6 +143,7 @@ Credential caching can be configured to optimize performance. By default, subscr
143
143
  ```ts
144
144
  export default defineNitroConfig({
145
145
  c8y: {
146
+ enableTenantOptionsInvalidationRoute: false,
146
147
  cache: {
147
148
  credentialsTTL: 300, // Cache credentials for 5 minutes (in seconds)
148
149
  defaultTenantOptionsTTL: 600, // Default cache for tenant options (in seconds)
@@ -179,6 +180,21 @@ C8Y_DEVELOPMENT_PASSWORD=your-password
179
180
 
180
181
  This enables testing of access control middlewares like `hasUserRequiredRole()` and `isUserFromAllowedTenant()` without needing to manually set authorization headers.
181
182
 
183
+ If you run a local proxy that already forwards a user session or authorization header, disable this middleware:
184
+
185
+ ```ts
186
+ export default defineNitroConfig({
187
+ c8y: {
188
+ dev: {
189
+ injectUser: false,
190
+ },
191
+ },
192
+ modules: [c8y()],
193
+ })
194
+ ```
195
+
196
+ When disabled, `c8y-nitro` does not register the development user injection middleware, so incoming auth headers stay untouched.
197
+
182
198
  ### Managing Development User Roles
183
199
 
184
200
  Use the [CLI roles command](#cli-commands) to assign or remove your microservice's custom roles to your development user:
@@ -440,7 +456,7 @@ export default defineNitroConfig({
440
456
  manifest: {
441
457
  settings: [
442
458
  { key: 'myOption', defaultValue: 'default' },
443
- { key: 'credentials.secret' }, // Encrypted option
459
+ { key: 'credentials.secret', defaultValue: 'change-me' }, // Encrypted option
444
460
  ],
445
461
  settingsCategory: 'my-service', // Optional, defaults to contextPath/name
446
462
  requiredRoles: ['ROLE_OPTION_MANAGEMENT_READ'], // Required for reading tenant options
@@ -450,9 +466,11 @@ export default defineNitroConfig({
450
466
  })
451
467
  ```
452
468
 
469
+ `manifest.settings[].defaultValue` is required and must be a non-empty string. `''` is rejected during manifest generation so invalid settings fail early during development/build.
470
+
453
471
  > **Important**: To read tenant options, your microservice **must** have the `ROLE_OPTION_MANAGEMENT_READ` role in `manifest.requiredRoles`. Without this role, API calls will fail with a 403 Forbidden error.
454
472
 
455
- > **Note on Encrypted Options**: Keys prefixed with `credentials.` are stored encrypted by Cumulocity. The value is automatically decrypted when fetched if your microservice is the owner of the option (the option's category matches your microservice's `settingsCategory`, `contextPath`, or name). The `credentials.` prefix is automatically stripped when calling the API.
473
+ > **Note on Encrypted Options**: Keys prefixed with `credentials.` are stored encrypted by Cumulocity. See more details [here](https://cumulocity.com/api/core/#operation/postOptionCollectionResource).
456
474
 
457
475
  > **Note on Missing Options**: If a tenant option is not set (404 Not Found), `useTenantOption()` returns `undefined` instead of throwing an error. Other errors (e.g., 403 Forbidden) are thrown normally.
458
476
 
@@ -480,6 +498,8 @@ export default defineNitroConfig({
480
498
  | `isUserFromAllowedTenant(tenant\|tenants)` | Check if user is from allowed tenant(s) | ✅ |
481
499
  | `isUserFromDeployedTenant()` | Check if user is from the deployed tenant | ✅ |
482
500
 
501
+ Probe requests targeting the manifest-defined `livenessProbe.httpGet.path` or `readinessProbe.httpGet.path` bypass these auth helpers so orchestration health checks are not blocked by route-wide access control.
502
+
483
503
  ## CLI Commands
484
504
 
485
505
  | Command | Description |
@@ -1,6 +1,6 @@
1
- import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BBSKRwKs.mjs";
1
+ import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BgTNTqHd.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-B0BK-uZW.mjs";
3
- import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-Dqi-ttQi.mjs";
3
+ import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
4
4
  import { defineCommand, runCommand } from "citty";
5
5
  import { consola } from "consola";
6
6
  //#region src/cli/commands/bootstrap.ts
@@ -52,7 +52,7 @@ var bootstrap_default = defineCommand({
52
52
  });
53
53
  consola.success(`Bootstrap credentials written to ${envFileName}`);
54
54
  if (manifest.roles && manifest.roles.length > 0) {
55
- if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-DJxp2d8p.mjs").then((r) => r.default), { rawArgs: [] });
55
+ if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-CoBPqvDv.mjs").then((r) => r.default), { rawArgs: [] });
56
56
  }
57
57
  consola.success("Bootstrap complete!");
58
58
  }
@@ -3,8 +3,15 @@ import { Buffer } from "node:buffer";
3
3
  //#region src/module/constants.ts
4
4
  const GENERATED_LIVENESS_ROUTE = "/_c8y_nitro/liveness";
5
5
  const GENERATED_READINESS_ROUTE = "/_c8y_nitro/readiness";
6
+ const GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE = "/_c8y_nitro/invalidate-tenant-options";
6
7
  //#endregion
7
8
  //#region src/module/manifest.ts
9
+ function validateManifestSettings(options) {
10
+ const invalidSettings = options.settings?.filter((setting) => typeof setting.defaultValue !== "string" || setting.defaultValue.length === 0) ?? [];
11
+ if (invalidSettings.length === 0) return;
12
+ const invalidKeys = invalidSettings.map((setting) => `"${setting.key}"`).join(", ");
13
+ throw new Error(`manifest.settings entries must define a non-empty defaultValue. Invalid keys: ${invalidKeys}`);
14
+ }
8
15
  async function readPackageJsonFieldsForManifest(rootDir, logger) {
9
16
  logger?.debug(`Reading package file from ${rootDir}`);
10
17
  const pkg = await readPackage(rootDir);
@@ -37,6 +44,7 @@ async function readPackageJsonFieldsForManifest(rootDir, logger) {
37
44
  * @param logger - Optional logger for debug output
38
45
  */
39
46
  async function createC8yManifest(rootDir, options = {}, logger) {
47
+ validateManifestSettings(options);
40
48
  const { name, version, provider, ...restManifestFields } = await readPackageJsonFieldsForManifest(rootDir, logger);
41
49
  const probeFields = {};
42
50
  if (!options.livenessProbe?.httpGet) probeFields.livenessProbe = {
@@ -286,7 +294,35 @@ async function getTenantOption(baseUrl, category, key, authHeader) {
286
294
  return (await response.json()).value;
287
295
  }
288
296
  /**
289
- * Updates or creates a tenant option.
297
+ * Creates a tenant option.
298
+ * @param baseUrl - The Cumulocity base URL
299
+ * @param category - The category of the option
300
+ * @param key - The option key
301
+ * @param value - The value to set
302
+ * @param authHeader - The Basic Auth header
303
+ */
304
+ async function createTenantOption(baseUrl, category, key, value, authHeader) {
305
+ const url = `${baseUrl}/tenant/options`;
306
+ const response = await fetch(url, {
307
+ method: "POST",
308
+ headers: {
309
+ "Authorization": authHeader,
310
+ "Content-Type": "application/vnd.com.nsn.cumulocity.option+json",
311
+ "Accept": "application/json"
312
+ },
313
+ body: JSON.stringify({
314
+ category,
315
+ key,
316
+ value
317
+ })
318
+ });
319
+ if (!response.ok) {
320
+ const errorText = await response.text();
321
+ throw new Error(`Failed to create tenant option ${category}/${key}: ${response.status} ${response.statusText}\n${errorText}`, { cause: response });
322
+ }
323
+ }
324
+ /**
325
+ * Updates an existing tenant option.
290
326
  * @param baseUrl - The Cumulocity base URL
291
327
  * @param category - The category of the option
292
328
  * @param key - The option key
@@ -310,6 +346,25 @@ async function updateTenantOption(baseUrl, category, key, value, authHeader) {
310
346
  }
311
347
  }
312
348
  /**
349
+ * Updates a tenant option if it exists, otherwise creates it.
350
+ * @param baseUrl - The Cumulocity base URL
351
+ * @param category - The category of the option
352
+ * @param key - The option key
353
+ * @param value - The new value to set
354
+ * @param authHeader - The Basic Auth header
355
+ */
356
+ async function upsertTenantOption(baseUrl, category, key, value, authHeader) {
357
+ try {
358
+ await updateTenantOption(baseUrl, category, key, value, authHeader);
359
+ } catch (error) {
360
+ if (error.cause?.status === 404) {
361
+ await createTenantOption(baseUrl, category, key, value, authHeader);
362
+ return;
363
+ }
364
+ throw error;
365
+ }
366
+ }
367
+ /**
313
368
  * Deletes a tenant option.
314
369
  * @param baseUrl - The Cumulocity base URL
315
370
  * @param category - The category of the option
@@ -329,4 +384,4 @@ async function deleteTenantOption(baseUrl, category, key, authHeader) {
329
384
  }
330
385
  }
331
386
  //#endregion
332
- export { findMicroserviceByName as a, getTenantOptionsByCategory as c, updateMicroservice as d, updateTenantOption as f, GENERATED_READINESS_ROUTE as g, GENERATED_LIVENESS_ROUTE as h, deleteTenantOption as i, subscribeToApplication as l, createC8yManifestFromNitro as m, createBasicAuthHeader as n, getBootstrapCredentials as o, createC8yManifest as p, createMicroservice as r, getTenantOption as s, assignUserRole as t, unassignUserRole as u };
387
+ export { GENERATED_READINESS_ROUTE as _, findMicroserviceByName as a, getTenantOptionsByCategory as c, updateMicroservice as d, upsertTenantOption as f, GENERATED_LIVENESS_ROUTE as g, GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE as h, deleteTenantOption as i, subscribeToApplication as l, createC8yManifestFromNitro as m, createBasicAuthHeader as n, getBootstrapCredentials as o, createC8yManifest as p, createMicroservice as r, getTenantOption as s, assignUserRole as t, unassignUserRole as u };
@@ -1,4 +1,4 @@
1
- import { n as name, r as version, t as description } from "../package-CobwNpP9.mjs";
1
+ import { n as name, r as version, t as description } from "../package-bfMasPPg.mjs";
2
2
  import { defineCommand, runMain } from "citty";
3
3
  //#region src/cli/index.ts
4
4
  runMain(defineCommand({
@@ -8,9 +8,9 @@ runMain(defineCommand({
8
8
  description
9
9
  },
10
10
  subCommands: {
11
- bootstrap: () => import("../bootstrap-BqWPkH8q.mjs").then((r) => r.default),
12
- roles: () => import("../roles-DJxp2d8p.mjs").then((r) => r.default),
13
- options: () => import("../options-BDDJWdph.mjs").then((r) => r.default)
11
+ bootstrap: () => import("../bootstrap-BSdVwFWO.mjs").then((r) => r.default),
12
+ roles: () => import("../roles-CoBPqvDv.mjs").then((r) => r.default),
13
+ options: () => import("../options-BkWL1FOa.mjs").then((r) => r.default)
14
14
  }
15
15
  }));
16
16
  //#endregion
@@ -2,6 +2,9 @@ import { dirname } from "pathe";
2
2
  import { loadConfig, loadDotenv } from "c12";
3
3
  import process from "process";
4
4
  //#region src/cli/utils/config.ts
5
+ function normalizeBaseUrl(baseUrl) {
6
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
7
+ }
5
8
  /**
6
9
  * Loads c8y configuration from nitro.config and .env files.
7
10
  * Searches in cwd.
@@ -45,7 +48,7 @@ function validateBootstrapEnv(env) {
45
48
  const missing = REQUIRED_BOOTSTRAP_ENV_VARS.filter((key) => !env[key]);
46
49
  if (missing.length > 0) throw new Error(`Missing required environment variables:\n${missing.map((k) => ` - ${k}`).join("\n")}\n\nPlease set these in your .env or .env.local file.`);
47
50
  return {
48
- C8Y_BASEURL: env.C8Y_BASEURL.endsWith("/") ? env.C8Y_BASEURL.slice(0, -1) : env.C8Y_BASEURL,
51
+ C8Y_BASEURL: normalizeBaseUrl(env.C8Y_BASEURL),
49
52
  C8Y_DEVELOPMENT_TENANT: env.C8Y_DEVELOPMENT_TENANT,
50
53
  C8Y_DEVELOPMENT_USER: env.C8Y_DEVELOPMENT_USER,
51
54
  C8Y_DEVELOPMENT_PASSWORD: env.C8Y_DEVELOPMENT_PASSWORD
@@ -193,10 +193,10 @@ interface Option {
193
193
  */
194
194
  key: string;
195
195
  /**
196
- * Initial value if not overridden.
196
+ * Initial non-empty value if not overridden.
197
197
  * @example "1234"
198
198
  */
199
- defaultValue?: string;
199
+ defaultValue: string;
200
200
  /**
201
201
  * Allow tenants to modify at runtime.
202
202
  * @default false
@@ -389,11 +389,34 @@ type C8YTenantOptionKeysCacheConfig$1 = Partial<Record<C8YTenantOptionKey$1, num
389
389
  type C8YTenantOptionKey$1 = string;
390
390
  //#endregion
391
391
  //#region src/types/index.d.ts
392
+ interface C8yDevOptions {
393
+ /**
394
+ * Automatically inject the configured development user into incoming requests
395
+ * during local Nitro dev mode.
396
+ *
397
+ * Disable this when a local proxy already forwards the desired user context.
398
+ * @default true
399
+ */
400
+ injectUser?: boolean;
401
+ }
392
402
  interface C8yNitroModuleOptions {
403
+ dev?: C8yDevOptions;
393
404
  manifest?: C8YManifestOptions;
394
405
  apiClient?: C8YAPIClientOptions;
395
406
  zip?: C8YZipOptions;
396
407
  cache?: C8yCacheOptions;
408
+ /**
409
+ * Adds a debug route for invalidating already-created tenant option caches.
410
+ * Exposes `GET /_c8y_nitro/invalidate-tenant-options`.
411
+ *
412
+ * Query params:
413
+ * - `all`: invalidate all created tenant option fetchers
414
+ * - `key`: invalidate a single manifest-defined tenant option key if its fetcher exists
415
+ *
416
+ * `all` takes priority over `key`.
417
+ * @default false
418
+ */
419
+ enableTenantOptionsInvalidationRoute?: boolean;
397
420
  /**
398
421
  * Disable auto-bootstrap during development.
399
422
  * When true, the module will not automatically register the microservice
@@ -405,4 +428,4 @@ interface C8yNitroModuleOptions {
405
428
  skipBootstrap?: boolean;
406
429
  }
407
430
  //#endregion
408
- export { C8yCacheOptions as a, C8YManifestOptions as c, C8YRoles$1 as i, C8YAPIClientOptions as l, C8YTenantOptionKey$1 as n, C8YZipOptions as o, C8YTenantOptionKeysCacheConfig$1 as r, C8YManifest as s, C8yNitroModuleOptions as t };
431
+ export { C8YRoles$1 as a, C8YManifest as c, C8YTenantOptionKeysCacheConfig$1 as i, C8YManifestOptions as l, C8yNitroModuleOptions as n, C8yCacheOptions as o, C8YTenantOptionKey$1 as r, C8YZipOptions as s, C8yDevOptions as t, C8YAPIClientOptions as u };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as C8yNitroModuleOptions } from "./index-CzUqbp5C.mjs";
1
+ import { n as C8yNitroModuleOptions } from "./index-Bvu7DqDt.mjs";
2
2
  import { NitroModule } from "nitro/types";
3
3
 
4
4
  //#region src/index.d.ts
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { a as findMicroserviceByName, g as GENERATED_READINESS_ROUTE, h as GENERATED_LIVENESS_ROUTE, l as subscribeToApplication, m as createC8yManifestFromNitro, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BBSKRwKs.mjs";
1
+ import { _ as GENERATED_READINESS_ROUTE, a as findMicroserviceByName, g as GENERATED_LIVENESS_ROUTE, h as GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE, l as subscribeToApplication, m as createC8yManifestFromNitro, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-BgTNTqHd.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-B0BK-uZW.mjs";
3
- import { n as name } from "./package-CobwNpP9.mjs";
3
+ import { n as name } from "./package-bfMasPPg.mjs";
4
4
  import { basename, dirname, join, relative } from "node:path";
5
5
  import { mkdir, writeFile } from "node:fs/promises";
6
6
  import { x } from "tinyexec";
@@ -427,11 +427,21 @@ async function setupRuntimeConfig(nitro, options) {
427
427
  */
428
428
  function registerRuntime(nitro, options = {}) {
429
429
  const thisFilePath = fileURLToPath(new URL(".", import.meta.url));
430
+ const isNitroDev = nitro.options.preset === "nitro-dev";
431
+ const shouldIncludeRuntimeFile = (relativePath) => {
432
+ if (relativePath.endsWith(".dev.ts") && !isNitroDev) return false;
433
+ if (relativePath.endsWith("/dev-user.dev.ts") && options.dev?.injectUser === false) return false;
434
+ return true;
435
+ };
436
+ const toRuntimePath = (relativePath) => join$1(thisFilePath, relativePath.replace(/\.ts$/, ""));
430
437
  const allPlugins = Object.keys({
431
- "./runtime/plugins/c8y-variables.ts": 0,
438
+ "./runtime/plugins/c8y-variables.dev.ts": 0,
432
439
  "./runtime/plugins/enrich-logs.ts": 0
433
- }).map((p) => join$1(thisFilePath, p.replace(".ts", "")));
434
- const allMiddlewares = Object.keys({ "./runtime/middlewares/dev-user.ts": 0 }).map((p) => join$1(thisFilePath, p.replace(".ts", "")));
440
+ }).filter(shouldIncludeRuntimeFile).map(toRuntimePath);
441
+ const allMiddlewares = Object.keys({
442
+ "./runtime/middlewares/c8y-client.ts": 0,
443
+ "./runtime/middlewares/dev-user.dev.ts": 0
444
+ }).filter(shouldIncludeRuntimeFile).map(toRuntimePath);
435
445
  /**
436
446
  * Plugins (auto scanned)
437
447
  */
@@ -465,6 +475,15 @@ function registerRuntime(nitro, options = {}) {
465
475
  });
466
476
  nitro.logger.debug(`Generated readiness probe at ${GENERATED_READINESS_ROUTE}`);
467
477
  } else nitro.logger.debug("Readiness probe httpGet defined by user; skipping generation");
478
+ if (options.enableTenantOptionsInvalidationRoute) {
479
+ const invalidateTenantOptionsHandlerPath = join$1(thisFilePath, "./runtime/handlers/invalidateTenantOptions");
480
+ handlers.push({
481
+ route: GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE,
482
+ handler: invalidateTenantOptionsHandlerPath,
483
+ method: "GET"
484
+ });
485
+ nitro.logger.debug(`Generated tenant option invalidation route at ${GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE}`);
486
+ }
468
487
  nitro.options.handlers.push(...handlers);
469
488
  }
470
489
  //#endregion
@@ -558,12 +577,25 @@ function c8y() {
558
577
  name,
559
578
  "@c8y/client"
560
579
  ];
580
+ nitro.options.rolldownConfig = {
581
+ ...nitro.options.rolldownConfig,
582
+ resolve: {
583
+ ...nitro.options.rolldownConfig?.resolve,
584
+ alias: {
585
+ ...nitro.options.rolldownConfig?.resolve?.alias,
586
+ tslib: "tslib/tslib.es6.mjs"
587
+ }
588
+ }
589
+ };
561
590
  if (!nitro.options.preset.startsWith("nitro") && !nitro.options.preset.startsWith("node")) {
562
591
  nitro.logger.error(`Unsupported preset "${nitro.options.preset}" for c8y-nitro module, only node presets are supported.`);
563
592
  throw new Error("Unsupported preset for c8y-nitro module");
564
593
  }
565
594
  let manifest = await createC8yManifestFromNitro(nitro);
566
- const { setup: setupEvlog } = evlog({ env: { service: manifest.name } });
595
+ const { setup: setupEvlog } = evlog({
596
+ env: { service: manifest.name },
597
+ exclude: [manifest.livenessProbe?.httpGet?.path, manifest.readinessProbe?.httpGet?.path].filter(Boolean)
598
+ });
567
599
  await setupEvlog(nitro);
568
600
  if (!options.skipBootstrap) await autoBootstrap(nitro);
569
601
  await setupRuntimeConfig(nitro, options);
@@ -0,0 +1,3 @@
1
+ import "evlog/nitro/v3";
2
+ import { createError } from "evlog";
3
+ export { createError as t };
@@ -1,5 +1,5 @@
1
- import { c as getTenantOptionsByCategory, f as updateTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getTenantOption } from "./c8y-api-BBSKRwKs.mjs";
2
- import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-Dqi-ttQi.mjs";
1
+ import { c as getTenantOptionsByCategory, f as upsertTenantOption, i as deleteTenantOption, n as createBasicAuthHeader, p as createC8yManifest, s as getTenantOption } from "./c8y-api-BgTNTqHd.mjs";
2
+ import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
5
5
  //#region src/cli/commands/options.ts
@@ -62,6 +62,7 @@ async function handleRead(baseUrl, category, authHeader, availableKeys, currentO
62
62
  consola.warn("No options are currently set");
63
63
  return;
64
64
  }
65
+ if (credentialsKeys.length > 0) consola.warn("Encrypted credentials.* options cannot be read in decrypted form with the development user in this CLI.");
65
66
  const key = await consola.prompt("Select option to read:", {
66
67
  type: "select",
67
68
  options: allKeys.map((k) => ({
@@ -71,7 +72,7 @@ async function handleRead(baseUrl, category, authHeader, availableKeys, currentO
71
72
  cancel: "reject"
72
73
  });
73
74
  consola.info(`Reading option: ${key}`);
74
- const value = await getTenantOption(baseUrl, category, key.startsWith("credentials.") ? key.replace(/^credentials\./, "") : key, authHeader);
75
+ const value = await getTenantOption(baseUrl, category, key, authHeader);
75
76
  if (value === void 0) consola.warn(`Option '${key}' is not set`);
76
77
  else consola.success(`Value: ${value}`);
77
78
  }
@@ -101,7 +102,7 @@ async function handleUpdate(baseUrl, category, authHeader, availableKeys, curren
101
102
  cancel: "reject"
102
103
  });
103
104
  consola.info(`Updating option: ${key}`);
104
- await updateTenantOption(baseUrl, category, key.replace(/^credentials\./, ""), newValue, authHeader);
105
+ await upsertTenantOption(baseUrl, category, key, newValue, authHeader);
105
106
  currentOptions[key] = newValue;
106
107
  consola.success(`Option '${key}' updated successfully`);
107
108
  if (!await consola.prompt("Update another option?", {
@@ -136,7 +137,8 @@ async function handleDelete(baseUrl, category, authHeader, availableKeys, curren
136
137
  consola.info(`Deleting ${keysToDelete.length} option(s)...`);
137
138
  for (const key of keysToDelete) {
138
139
  consola.info(`Deleting option: ${key}`);
139
- await deleteTenantOption(baseUrl, category, key.startsWith("credentials.") ? key.replace(/^credentials\./, "") : key, authHeader);
140
+ await deleteTenantOption(baseUrl, category, key, authHeader);
141
+ delete currentOptions[key];
140
142
  consola.success(`✓ Deleted: ${key}`);
141
143
  }
142
144
  consola.success("Delete operation completed");
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "c8y-nitro";
3
- var version = "0.4.1";
3
+ var version = "0.5.0";
4
4
  var description = "Lightning fast Cumulocity IoT microservice development powered by Nitro";
5
5
  //#endregion
6
6
  export { name as n, version as r, description as t };
@@ -1,5 +1,5 @@
1
- import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-BBSKRwKs.mjs";
2
- import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-Dqi-ttQi.mjs";
1
+ import { n as createBasicAuthHeader, p as createC8yManifest, t as assignUserRole, u as unassignUserRole } from "./c8y-api-BgTNTqHd.mjs";
2
+ import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BRnvtthI.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
5
5
  //#region src/cli/commands/roles.ts
@@ -0,0 +1,35 @@
1
+ import { t as createError } from "../../logging-CVlaRS4O.mjs";
2
+ import { c8yTenantOptionKeys } from "c8y-nitro/runtime";
3
+ import { defineEventHandler, getQuery } from "nitro/h3";
4
+ //#region src/utils/internal/tenantOptionFetchers.ts
5
+ /**
6
+ * Internal storage for cached functions per key
7
+ */
8
+ const tenantOptionFetchers = {};
9
+ //#endregion
10
+ //#region src/module/runtime/handlers/invalidateTenantOptions.ts
11
+ var invalidateTenantOptions_default = defineEventHandler(async (event) => {
12
+ const query = getQuery(event);
13
+ if ("all" in query) {
14
+ await Promise.all(Object.values(tenantOptionFetchers).map((fetcher) => fetcher?.invalidate()));
15
+ return { message: "success" };
16
+ }
17
+ const key = Array.isArray(query.key) ? query.key[0] : query.key;
18
+ if (!key) throw createError({
19
+ status: 400,
20
+ message: "Provide either the all or key query parameter",
21
+ why: "The tenant option invalidation route requires an explicit target",
22
+ fix: "Use ?all to invalidate all created tenant option caches or ?key=<manifest setting key> to invalidate one created cache entry"
23
+ });
24
+ if (!c8yTenantOptionKeys.includes(key)) throw createError({
25
+ status: 400,
26
+ message: "Invalid tenant option invalidation request",
27
+ why: "Only tenant option keys declared in manifest.settings can be invalidated via this route",
28
+ fix: "Use ?all to invalidate all created tenant option caches or provide a valid manifest-defined key in ?key"
29
+ });
30
+ const fetcher = tenantOptionFetchers[key];
31
+ if (fetcher) await fetcher.invalidate();
32
+ return { message: "success" };
33
+ });
34
+ //#endregion
35
+ export { invalidateTenantOptions_default as default };
@@ -0,0 +1,43 @@
1
+ import { t as createError } from "../../logging-CVlaRS4O.mjs";
2
+ import { defineMiddleware } from "nitro";
3
+ import process from "node:process";
4
+ //#region src/module/runtime/middlewares/c8y-client.ts
5
+ function isC8yClientThrownResponse(error) {
6
+ if (!error || typeof error !== "object") return false;
7
+ if (!("res" in error) || !error.res || typeof error.res !== "object") return false;
8
+ const response = error.res;
9
+ if (typeof response.status !== "number") return false;
10
+ const responseUrl = typeof response.url === "string" ? response.url : void 0;
11
+ const baseUrl = process.env.C8Y_BASEURL?.replace(/\/+$/, "");
12
+ const matchesConfiguredTenant = Boolean(baseUrl && responseUrl?.startsWith(baseUrl));
13
+ if ("data" in error && error.data && typeof error.data === "object") {
14
+ const data = error.data;
15
+ if (typeof data.info === "string" && data.info.startsWith("https://cumulocity.com/")) return true;
16
+ }
17
+ return matchesConfiguredTenant;
18
+ }
19
+ var c8y_client_default = defineMiddleware(async (_event, next) => {
20
+ try {
21
+ return await next();
22
+ } catch (error) {
23
+ if (!isC8yClientThrownResponse(error)) throw error;
24
+ const upstreamMessage = error.data?.message || error.res.statusText || "Cumulocity request failed";
25
+ const upstreamCode = error.data?.error;
26
+ throw createError({
27
+ message: "Internal Server Error",
28
+ status: 500,
29
+ internal: {
30
+ help: "An error occurred while processing a request to Cumulocity from the @c8y/client library.",
31
+ upstream: "@c8y/client",
32
+ message: upstreamMessage,
33
+ code: upstreamCode,
34
+ status: error.res.status,
35
+ statusText: error.res.statusText,
36
+ url: error.res.url,
37
+ data: error.data
38
+ }
39
+ });
40
+ }
41
+ });
42
+ //#endregion
43
+ export { c8y_client_default as default };
@@ -2,8 +2,8 @@ import { defineHandler } from "nitro/h3";
2
2
  import process from "node:process";
3
3
  import { Buffer } from "node:buffer";
4
4
  import consola from "consola";
5
- //#region src/module/runtime/middlewares/dev-user.ts
6
- var dev_user_default = defineHandler((event) => {
5
+ //#region src/module/runtime/middlewares/dev-user.dev.ts
6
+ var dev_user_dev_default = defineHandler((event) => {
7
7
  if (import.meta.dev) {
8
8
  const missingDevVars = [
9
9
  "C8Y_DEVELOPMENT_TENANT",
@@ -20,4 +20,4 @@ var dev_user_default = defineHandler((event) => {
20
20
  }
21
21
  });
22
22
  //#endregion
23
- export { dev_user_default as default };
23
+ export { dev_user_dev_default as default };
@@ -1,7 +1,7 @@
1
- import process from "node:process";
2
1
  import { definePlugin } from "nitro";
3
- //#region src/module/runtime/plugins/c8y-variables.ts
4
- var c8y_variables_default = definePlugin(() => {
2
+ import process from "node:process";
3
+ //#region src/module/runtime/plugins/c8y-variables.dev.ts
4
+ var c8y_variables_dev_default = definePlugin(() => {
5
5
  if (import.meta.dev) {
6
6
  const env = process.env;
7
7
  const missingVars = [
@@ -14,4 +14,4 @@ var c8y_variables_default = definePlugin(() => {
14
14
  }
15
15
  });
16
16
  //#endregion
17
- export { c8y_variables_default as default };
17
+ export { c8y_variables_dev_default as default };
@@ -1,5 +1,5 @@
1
- import { definePlugin } from "nitro";
2
1
  import { c8yManifest } from "c8y-nitro/runtime";
2
+ import { definePlugin } from "nitro";
3
3
  //#region src/module/runtime/plugins/enrich-logs.ts
4
4
  var enrich_logs_default = definePlugin(async (nitroApp) => {
5
5
  nitroApp.hooks.hook("evlog:enrich", (enrichContext) => {
package/dist/types.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as C8yCacheOptions, c as C8YManifestOptions, i as C8YRoles, l as C8YAPIClientOptions, n as C8YTenantOptionKey, o as C8YZipOptions, r as C8YTenantOptionKeysCacheConfig, s as C8YManifest, t as C8yNitroModuleOptions } from "./index-CzUqbp5C.mjs";
2
- export { C8YAPIClientOptions, C8YManifest, C8YManifestOptions, C8YRoles, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, C8YZipOptions, C8yCacheOptions, C8yNitroModuleOptions };
1
+ import { a as C8YRoles, c as C8YManifest, i as C8YTenantOptionKeysCacheConfig, l as C8YManifestOptions, n as C8yNitroModuleOptions, o as C8yCacheOptions, r as C8YTenantOptionKey, s as C8YZipOptions, t as C8yDevOptions, u as C8YAPIClientOptions } from "./index-Bvu7DqDt.mjs";
2
+ export { C8YAPIClientOptions, C8YManifest, C8YManifestOptions, C8YRoles, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, C8YZipOptions, C8yCacheOptions, C8yDevOptions, C8yNitroModuleOptions };
package/dist/utils.d.mts CHANGED
@@ -242,11 +242,6 @@ declare function useUserTenantCredentials(requestOrEvent: ServerRequest | H3Even
242
242
  * - `c8y.cache.tenantOptions` — Per-key TTL overrides
243
243
  * - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
244
244
  *
245
- * @note For encrypted options (keys starting with `credentials.`), the value is automatically
246
- * decrypted by Cumulocity if this microservice is the owner of the option (category matches
247
- * the microservice's settingsCategory/contextPath/name). The `credentials.` prefix is
248
- * automatically stripped when calling the API.
249
- *
250
245
  * @example
251
246
  * // Fetch a tenant option:
252
247
  * const value = await useTenantOption('myOption')
package/dist/utils.mjs CHANGED
@@ -1,10 +1,13 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import process from "node:process";
2
3
  import { useLogger } from "evlog/nitro/v3";
3
4
  import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
4
5
  import { defineCachedFunction } from "nitro/cache";
6
+ import { createHash, randomBytes } from "node:crypto";
5
7
  import { HTTPError, defineHandler } from "nitro/h3";
6
8
  import { useStorage } from "nitro/storage";
7
9
  import { useRuntimeConfig } from "nitro/runtime-config";
10
+ import { c8yManifest } from "c8y-nitro/runtime";
8
11
  import { createError, createLogger } from "evlog";
9
12
  //#region src/utils/internal/common.ts
10
13
  /**
@@ -30,6 +33,55 @@ function convertRequestHeadersToC8yFormat(request) {
30
33
  return headers;
31
34
  }
32
35
  //#endregion
36
+ //#region src/utils/internal/tenant.ts
37
+ const USER_TENANT_CACHE_SALT = randomBytes(32).toString("hex");
38
+ function getCookieValue(cookieHeader, name) {
39
+ try {
40
+ const value = cookieHeader?.match(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`);
41
+ return value ? value.pop() : void 0;
42
+ } catch {
43
+ return;
44
+ }
45
+ }
46
+ function getCurrentUserTenantCacheKeyMaterial(requestOrEvent) {
47
+ const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
48
+ const cookieAuth = getCookieValue(request.headers.get("cookie"), "authorization");
49
+ if (cookieAuth) return `cookie:${cookieAuth}`;
50
+ const authorization = request.headers.get("authorization");
51
+ if (authorization) return `header:${authorization}`;
52
+ }
53
+ function createCurrentUserTenantCacheKey(requestOrEvent) {
54
+ const material = getCurrentUserTenantCacheKeyMaterial(requestOrEvent);
55
+ if (!material) throw new Error("Cannot create current user tenant cache key without auth material");
56
+ return createHash("sha256").update(USER_TENANT_CACHE_SALT).update(":").update(material).digest("hex");
57
+ }
58
+ function tryGetTenantFromBasicAuth(requestOrEvent) {
59
+ const authorization = ("req" in requestOrEvent ? requestOrEvent.req : requestOrEvent).headers.get("authorization");
60
+ if (!authorization?.startsWith("Basic ")) return;
61
+ try {
62
+ const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
63
+ const separatorIndex = decoded.indexOf(":");
64
+ const userPart = separatorIndex === -1 ? decoded : decoded.slice(0, separatorIndex);
65
+ const slashIndex = userPart.indexOf("/");
66
+ if (slashIndex <= 0) return;
67
+ return userPart.slice(0, slashIndex);
68
+ } catch {
69
+ return;
70
+ }
71
+ }
72
+ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
73
+ const basicTenant = tryGetTenantFromBasicAuth(requestOrEvent);
74
+ if (basicTenant) return basicTenant;
75
+ return (await useUserClient(requestOrEvent).tenant.current()).data.name;
76
+ }, {
77
+ maxAge: 60,
78
+ name: "_c8y_nitro_get_current_user_tenant_id",
79
+ group: "c8y_nitro",
80
+ swr: false,
81
+ getKey: (requestOrEvent) => createCurrentUserTenantCacheKey(requestOrEvent),
82
+ shouldBypassCache: (requestOrEvent) => !getCurrentUserTenantCacheKeyMaterial(requestOrEvent)
83
+ });
84
+ //#endregion
33
85
  //#region src/utils/credentials.ts
34
86
  /**
35
87
  * Fetches credentials for all tenants subscribed to this microservice.\
@@ -128,7 +180,7 @@ const useDeployedTenantCredentials = Object.assign(async () => {
128
180
  async function useUserTenantCredentials(requestOrEvent) {
129
181
  const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
130
182
  if (request.context?.["c8y_user_tenant_credentials"]) return request.context["c8y_user_tenant_credentials"];
131
- const tenantId = useUserClient(requestOrEvent).core.tenant;
183
+ const tenantId = await getCurrentUserTenantId(requestOrEvent);
132
184
  const userTenantCreds = (await useSubscribedTenantCredentials())[tenantId];
133
185
  if (!userTenantCreds) throw new HTTPError({
134
186
  message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
@@ -172,7 +224,7 @@ function useUserClient(requestOrEvent) {
172
224
  async function useUserTenantClient(requestOrEvent) {
173
225
  const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
174
226
  if (request.context?.["c8y_user_tenant_client"]) return request.context["c8y_user_tenant_client"];
175
- const tenantId = useUserClient(requestOrEvent).core.tenant;
227
+ const tenantId = await getCurrentUserTenantId(requestOrEvent);
176
228
  const creds = await useSubscribedTenantCredentials();
177
229
  if (!creds[tenantId]) throw new HTTPError({
178
230
  message: `No subscribed tenant credentials found for user tenant '${tenantId}'`,
@@ -267,8 +319,13 @@ async function useUserRoles(requestOrEvent) {
267
319
  }
268
320
  //#endregion
269
321
  //#region src/utils/middleware.ts
322
+ const probePaths = [c8yManifest.livenessProbe?.httpGet?.path, c8yManifest.readinessProbe?.httpGet?.path].filter((path) => Boolean(path));
323
+ function isProbeRequest(pathname) {
324
+ return probePaths.some((probePath) => pathname.startsWith(probePath));
325
+ }
270
326
  function hasUserRequiredRole(roleOrRoles) {
271
327
  return defineHandler(async (event) => {
328
+ if (isProbeRequest(event.url.pathname)) return;
272
329
  const requiredRoles = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
273
330
  const userRoles = await useUserRoles(event);
274
331
  if (!requiredRoles.some((role) => userRoles.includes(role))) throw new HTTPError({
@@ -280,8 +337,9 @@ function hasUserRequiredRole(roleOrRoles) {
280
337
  }
281
338
  function isUserFromAllowedTenant(tenantIdOrIds) {
282
339
  return defineHandler(async (event) => {
340
+ if (isProbeRequest(event.url.pathname)) return;
283
341
  const allowedTenants = Array.isArray(tenantIdOrIds) ? tenantIdOrIds : [tenantIdOrIds];
284
- const userTenantId = useUserClient(event).core.tenant;
342
+ const userTenantId = await getCurrentUserTenantId(event);
285
343
  if (!allowedTenants.includes(userTenantId)) throw new HTTPError({
286
344
  status: 403,
287
345
  statusText: "Forbidden",
@@ -306,7 +364,8 @@ function isUserFromAllowedTenant(tenantIdOrIds) {
306
364
  */
307
365
  function isUserFromDeployedTenant() {
308
366
  return defineHandler(async (event) => {
309
- const userTenantId = useUserClient(event).core.tenant;
367
+ if (isProbeRequest(event.url.pathname)) return;
368
+ const userTenantId = await getCurrentUserTenantId(event);
310
369
  const deployedTenantId = process.env.C8Y_BOOTSTRAP_TENANT;
311
370
  if (!deployedTenantId) throw new HTTPError({
312
371
  status: 500,
@@ -321,6 +380,12 @@ function isUserFromDeployedTenant() {
321
380
  });
322
381
  }
323
382
  //#endregion
383
+ //#region src/utils/internal/tenantOptionFetchers.ts
384
+ /**
385
+ * Internal storage for cached functions per key
386
+ */
387
+ const tenantOptionFetchers = {};
388
+ //#endregion
324
389
  //#region src/utils/tenantOptions.ts
325
390
  /**
326
391
  * Gets the cache TTL for a specific tenant option key.
@@ -332,10 +397,6 @@ function getTenantOptionCacheTTL(key) {
332
397
  return config.c8yTenantOptionsPerKeyTTL?.[key] ?? config.c8yDefaultTenantOptionsTTL ?? 600;
333
398
  }
334
399
  /**
335
- * Internal storage for cached functions per key
336
- */
337
- const tenantOptionFetchers = {};
338
- /**
339
400
  * Factory function that creates a cached fetcher for a specific tenant option key.
340
401
  * @param key - The tenant option key
341
402
  */
@@ -344,10 +405,9 @@ function createCachedTenantOptionFetcher(key) {
344
405
  const fetcher = defineCachedFunction(async () => {
345
406
  const client = await useDeployedTenantClient();
346
407
  const category = useRuntimeConfig().c8ySettingsCategory;
347
- const apiKey = key.replace(/^credentials\./, "");
348
408
  try {
349
409
  return (await client.options.tenant.detail({
350
- key: apiKey,
410
+ key,
351
411
  category
352
412
  })).data.value;
353
413
  } catch (error) {
@@ -397,11 +457,6 @@ function getOrCreateFetcher(key) {
397
457
  * - `c8y.cache.tenantOptions` — Per-key TTL overrides
398
458
  * - `NITRO_C8Y_DEFAULT_TENANT_OPTIONS_TTL` — Environment variable for default TTL
399
459
  *
400
- * @note For encrypted options (keys starting with `credentials.`), the value is automatically
401
- * decrypted by Cumulocity if this microservice is the owner of the option (category matches
402
- * the microservice's settingsCategory/contextPath/name). The `credentials.` prefix is
403
- * automatically stripped when calling the API.
404
- *
405
460
  * @example
406
461
  * // Fetch a tenant option:
407
462
  * const value = await useTenantOption('myOption')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8y-nitro",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Lightning fast Cumulocity IoT microservice development powered by Nitro",
6
6
  "keywords": [
@@ -23,22 +23,12 @@
23
23
  "url": "https://github.com/schplitt/c8y-nitro/issues"
24
24
  },
25
25
  "exports": {
26
- ".": {
27
- "types": "./dist/index.d.mts",
28
- "import": "./dist/index.mjs"
29
- },
30
- "./types": {
31
- "types": "./dist/types.d.mts",
32
- "import": "./dist/types.mjs"
33
- },
34
- "./utils": {
35
- "types": "./dist/utils.d.mts",
36
- "import": "./dist/utils.mjs"
37
- }
26
+ ".": "./dist/index.mjs",
27
+ "./cli": "./dist/cli/index.mjs",
28
+ "./types": "./dist/types.mjs",
29
+ "./utils": "./dist/utils.mjs",
30
+ "./package.json": "./package.json"
38
31
  },
39
- "main": "./dist/index.mjs",
40
- "module": "./dist/index.mjs",
41
- "types": "./dist/index.d.mts",
42
32
  "bin": {
43
33
  "c8y-nitro": "./dist/cli/index.mjs"
44
34
  },
@@ -49,25 +39,26 @@
49
39
  ],
50
40
  "dependencies": {
51
41
  "c12": "^4.0.0-beta.4",
52
- "citty": "^0.2.1",
42
+ "citty": "^0.2.2",
53
43
  "consola": "^3.4.2",
54
- "evlog": "^2.9.0",
44
+ "evlog": "^2.11.1",
55
45
  "jszip": "^3.10.1",
56
46
  "pathe": "^2.0.3",
57
47
  "pkg-types": "^2.3.0",
58
48
  "spinnies": "^0.5.1",
59
- "tinyexec": "^1.0.4"
49
+ "tinyexec": "^1.1.1",
50
+ "tsnapi": "^0.1.1"
60
51
  },
61
52
  "devDependencies": {
62
53
  "@schplitt/eslint-config": "^1.3.1",
63
54
  "@types/spinnies": "^0.5.3",
64
55
  "bumpp": "^11.0.1",
65
56
  "changelogithub": "^14.0.0",
66
- "eslint": "^10.1.0",
57
+ "eslint": "^10.2.0",
67
58
  "memfs": "^4.57.1",
68
- "tsdown": "^0.21.4",
59
+ "tsdown": "^0.21.7",
69
60
  "typescript": "^5.9.3",
70
- "vitest": "^4.1.0"
61
+ "vitest": "^4.1.4"
71
62
  },
72
63
  "peerDependencies": {
73
64
  "@c8y/client": ">=1021",
@@ -79,6 +70,7 @@
79
70
  "scripts": {
80
71
  "dev": "tsdown --watch",
81
72
  "build": "tsdown",
73
+ "build:update": "tsdown --update-snapshot",
82
74
  "lint": "eslint",
83
75
  "lint:fix": "eslint --fix",
84
76
  "typecheck": "tsc --noEmit",