c8y-nitro 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { n as name, r as version, t as description } from "../package-BhFLRPOb.mjs";
1
+ import { n as name, r as version, t as description } from "../package-D5xRshsx.mjs";
2
2
  import { defineCommand, runMain } from "citty";
3
3
  //#region src/cli/index.ts
4
4
  runMain(defineCommand({
@@ -0,0 +1,236 @@
1
+ import { t as createError } from "./logging-BUYT8Z2V.mjs";
2
+ import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
3
+ import { defineCachedFunction } from "nitro/cache";
4
+ import { Buffer } from "node:buffer";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import process from "node:process";
7
+ import { useRuntimeConfig } from "nitro/runtime-config";
8
+ import { useNitroHooks } from "nitro/app";
9
+ //#region src/utils/internal/common.ts
10
+ /**
11
+ * Converts undici Request headers to the format expected by MicroserviceClientRequestAuth.\
12
+ * Extracts the following headers from the request:
13
+ * - `authorization`: Used for Basic Auth or Bearer token authentication
14
+ * - `cookie`: Used to extract XSRF-TOKEN and authorization token from cookies
15
+ *
16
+ * The MicroserviceClientRequestAuth class will automatically:
17
+ * - Extract XSRF-TOKEN from cookies for CSRF protection
18
+ * - Extract authorization token from cookies (prioritized over header auth)
19
+ * - Fall back to Authorization header if no cookie-based auth is present
20
+ *
21
+ * @param request - The HTTP request containing headers
22
+ * @returns Headers object compatible with \@c8y/client's MicroserviceClientRequestAuth
23
+ */
24
+ function convertRequestHeadersToC8yFormat(request) {
25
+ const headers = {};
26
+ const authorization = request.headers.get("authorization");
27
+ if (authorization) headers.authorization = authorization;
28
+ const cookie = request.headers.get("cookie");
29
+ if (cookie) headers.cookie = cookie;
30
+ return headers;
31
+ }
32
+ //#endregion
33
+ //#region src/utils/internal/tenant.ts
34
+ const USER_TENANT_CACHE_SALT = randomBytes(32).toString("hex");
35
+ function getCookieValue(cookieHeader, name) {
36
+ try {
37
+ const value = cookieHeader?.match(`(^|;)\\s*${name}\\s*=\\s*([^;]+)`);
38
+ return value ? value.pop() : void 0;
39
+ } catch {
40
+ return;
41
+ }
42
+ }
43
+ function getCurrentUserTenantCacheKeyMaterial(requestOrEvent) {
44
+ const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
45
+ const cookieAuth = getCookieValue(request.headers.get("cookie"), "authorization");
46
+ if (cookieAuth) return `cookie:${cookieAuth}`;
47
+ const authorization = request.headers.get("authorization");
48
+ if (authorization) return `header:${authorization}`;
49
+ }
50
+ function createCurrentUserTenantCacheKey(requestOrEvent) {
51
+ const material = getCurrentUserTenantCacheKeyMaterial(requestOrEvent);
52
+ if (!material) throw new Error("Cannot create current user tenant cache key without auth material");
53
+ return createHash("sha256").update(USER_TENANT_CACHE_SALT).update(":").update(material).digest("hex");
54
+ }
55
+ function tryGetTenantFromBasicAuth(requestOrEvent) {
56
+ const authorization = ("req" in requestOrEvent ? requestOrEvent.req : requestOrEvent).headers.get("authorization");
57
+ if (!authorization?.startsWith("Basic ")) return;
58
+ try {
59
+ const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
60
+ const separatorIndex = decoded.indexOf(":");
61
+ const userPart = separatorIndex === -1 ? decoded : decoded.slice(0, separatorIndex);
62
+ const slashIndex = userPart.indexOf("/");
63
+ if (slashIndex <= 0) return;
64
+ return userPart.slice(0, slashIndex);
65
+ } catch {
66
+ return;
67
+ }
68
+ }
69
+ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
70
+ const basicTenant = tryGetTenantFromBasicAuth(requestOrEvent);
71
+ if (basicTenant) return basicTenant;
72
+ return (await useUserClient(requestOrEvent).tenant.current()).data.name;
73
+ }, {
74
+ maxAge: 60,
75
+ name: "_c8y_nitro_get_current_user_tenant_id",
76
+ group: "c8y_nitro",
77
+ swr: false,
78
+ getKey: (requestOrEvent) => createCurrentUserTenantCacheKey(requestOrEvent),
79
+ shouldBypassCache: (requestOrEvent) => !getCurrentUserTenantCacheKeyMaterial(requestOrEvent)
80
+ });
81
+ const cachedTenantDomain = defineCachedFunction(async (requestOrEvent, _tenantId) => {
82
+ return (await useUserClient(requestOrEvent).tenant.current()).data.domainName;
83
+ }, {
84
+ maxAge: 86400,
85
+ name: "_c8y_nitro_get_user_tenant_domain",
86
+ group: "c8y_nitro",
87
+ swr: false,
88
+ getKey: (_requestOrEvent, tenantId) => tenantId
89
+ });
90
+ /**
91
+ * Resolves the public domain of the current user's tenant (e.g. `tenant.cumulocity.com`)
92
+ * via `/tenant/currentTenant` with the user's credentials from the current request.
93
+ * @param requestOrEvent - The H3Event or ServerRequest from the current request
94
+ */
95
+ async function getUserTenantDomain(requestOrEvent) {
96
+ const tenantId = await getCurrentUserTenantId(requestOrEvent);
97
+ return cachedTenantDomain(requestOrEvent, tenantId);
98
+ }
99
+ //#endregion
100
+ //#region src/utils/credentials.ts
101
+ let prevCredentials = null;
102
+ function shouldEmitTenantCredentialsUpdated(prev, next) {
103
+ if (!prev) return true;
104
+ const prevTenantIds = Object.keys(prev);
105
+ const nextTenantIds = Object.keys(next);
106
+ if (prevTenantIds.length !== nextTenantIds.length) return true;
107
+ return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
108
+ }
109
+ /**
110
+ * Fetches credentials for all tenants subscribed to this microservice.\
111
+ * Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
112
+ * Results are cached based on the configured TTL (default: 10 minutes).\
113
+ * @returns Object mapping tenant IDs to their respective credentials
114
+ * @config Cache TTL can be configured via:
115
+ * - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
116
+ * - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
117
+ * @example
118
+ * // Get all subscribed tenant credentials:
119
+ * const credentials = await useSubscribedTenantCredentials()
120
+ * console.log(Object.keys(credentials)) // ['t12345', 't67890']
121
+ *
122
+ * // Access specific tenant:
123
+ * const tenant1Creds = credentials['t12345']
124
+ *
125
+ * // Invalidate cache:
126
+ * await useSubscribedTenantCredentials.invalidate()
127
+ *
128
+ * // Force refresh:
129
+ * const freshCreds = await useSubscribedTenantCredentials.refresh()
130
+ */
131
+ const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
132
+ const newCredentials = (await Client.getMicroserviceSubscriptions({
133
+ tenant: process.env.C8Y_BOOTSTRAP_TENANT,
134
+ user: process.env.C8Y_BOOTSTRAP_USER,
135
+ password: process.env.C8Y_BOOTSTRAP_PASSWORD
136
+ }, process.env.C8Y_BASEURL)).reduce((acc, cred) => {
137
+ if (cred.tenant) acc[cred.tenant] = cred;
138
+ return acc;
139
+ }, {});
140
+ if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
141
+ prevCredentials = newCredentials;
142
+ return newCredentials;
143
+ }, {
144
+ maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
145
+ name: "_c8y_nitro_get_subscribed_tenant_credentials",
146
+ group: "c8y_nitro",
147
+ swr: false
148
+ });
149
+ const useSubscribedTenantCredentials = Object.assign(async () => {
150
+ const credentials = await cachedSubscribedTenantCredentials();
151
+ prevCredentials = credentials;
152
+ return credentials;
153
+ }, {
154
+ invalidate: cachedSubscribedTenantCredentials.invalidate,
155
+ refresh: async () => {
156
+ await cachedSubscribedTenantCredentials.invalidate();
157
+ const credentials = await cachedSubscribedTenantCredentials();
158
+ prevCredentials = credentials;
159
+ return credentials;
160
+ }
161
+ });
162
+ /**
163
+ * Fetches credentials for the tenant where this microservice is deployed.\
164
+ * Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
165
+ * Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
166
+ * @returns Credentials for the deployed tenant
167
+ * @throws {EvlogError} If no credentials found for the deployed tenant
168
+ * @example
169
+ * // Get deployed tenant credentials:
170
+ * const creds = await useDeployedTenantCredentials()
171
+ * console.log(creds.tenant, creds.user)
172
+ *
173
+ * // Invalidate cache:
174
+ * await useDeployedTenantCredentials.invalidate()
175
+ *
176
+ * // Force refresh:
177
+ * const freshCreds = await useDeployedTenantCredentials.refresh()
178
+ * @note This function is not cached separately. It uses the cache of `useSubscribedTenantCredentials()`. Invalidating or refreshing one will refresh `useDeployedTenantCredentials()`s cache.
179
+ */
180
+ const useDeployedTenantCredentials = Object.assign(async () => {
181
+ const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
182
+ const allCredsPromise = await useSubscribedTenantCredentials();
183
+ if (!allCredsPromise[tenant]) throw createError({
184
+ message: `No credentials found for tenant deployed tenant '${tenant}'`,
185
+ status: 500
186
+ });
187
+ return allCredsPromise[tenant];
188
+ }, {
189
+ invalidate: useSubscribedTenantCredentials.invalidate,
190
+ refresh: async () => {
191
+ await useSubscribedTenantCredentials.refresh();
192
+ return await useDeployedTenantCredentials();
193
+ }
194
+ });
195
+ //#endregion
196
+ //#region src/utils/client.ts
197
+ /**
198
+ * Creates a Cumulocity client authenticated with the current user's credentials.\
199
+ * Extracts credentials from the Authorization header of the current request.
200
+ * @param requestOrEvent - The H3Event or ServerRequest from the current request
201
+ * @returns A configured Cumulocity Client instance
202
+ * @example
203
+ * // In a request handler:
204
+ * const client = useUserClient(event)
205
+ * const { data } = await client.inventory.list()
206
+ */
207
+ function useUserClient(requestOrEvent) {
208
+ const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
209
+ if (request.context?.["c8y_user_client"]) return request.context["c8y_user_client"];
210
+ const client = new Client(new MicroserviceClientRequestAuth(convertRequestHeadersToC8yFormat(request)), process.env.C8Y_BASEURL);
211
+ request.context ??= {};
212
+ request.context["c8y_user_client"] = client;
213
+ return client;
214
+ }
215
+ /**
216
+ * Creates a Cumulocity client for the tenant where this microservice is deployed.\
217
+ * Uses the bootstrap tenant ID from runtime config to identify the deployed tenant.\
218
+ * @returns A configured Cumulocity Client instance for the deployed tenant
219
+ * @example
220
+ * // Get client for the tenant hosting this microservice:
221
+ * const client = await useDeployedTenantClient()
222
+ * const { data } = await client.application.list()
223
+ */
224
+ async function useDeployedTenantClient() {
225
+ const creds = await useSubscribedTenantCredentials();
226
+ const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
227
+ if (!creds[tenant]) throw createError({
228
+ message: `No subscribed tenant credentials found for tenant '${tenant}'`,
229
+ status: 500
230
+ });
231
+ const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
232
+ client.core.tenant = tenant;
233
+ return client;
234
+ }
235
+ //#endregion
236
+ export { getUserTenantDomain as n, useDeployedTenantClient as t };
@@ -383,6 +383,30 @@ interface C8yCacheOptions {
383
383
  tenantOptions?: C8YTenantOptionKeysCacheConfig;
384
384
  }
385
385
  //#endregion
386
+ //#region src/types/docker.d.ts
387
+ interface C8yDockerOptions {
388
+ /**
389
+ * Base image for the generated Dockerfile.
390
+ *
391
+ * Use this when the default slim image is missing something the service
392
+ * needs at runtime (e.g. CA certificates, native runtime libraries).
393
+ * @default 'node:24-slim'
394
+ */
395
+ baseImage?: string;
396
+ /**
397
+ * Raw Dockerfile instructions inserted after `WORKDIR` and before the build
398
+ * output is copied, one instruction per entry.
399
+ *
400
+ * Placing them before the `COPY` keeps their layers cached across rebuilds.
401
+ * `ENV`, `EXPOSE 80` and the `CMD` entrypoint stay under module control and
402
+ * cannot be overridden — they are the Cumulocity microservice contract.
403
+ *
404
+ * @example ['RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*']
405
+ * @default []
406
+ */
407
+ extraInstructions?: string[];
408
+ }
409
+ //#endregion
386
410
  //#region src/types/roles.d.ts
387
411
  interface C8YRoles$1 {}
388
412
  //#endregion
@@ -415,12 +439,38 @@ interface C8yDevOptions {
415
439
  */
416
440
  injectUser?: boolean;
417
441
  }
442
+ interface C8yOpenAPIOptions {
443
+ /**
444
+ * Strip internal routes from the served OpenAPI document.
445
+ * Removes every route starting with `/_` (generated probes, `/_c8y_nitro/**`
446
+ * and the OpenAPI/Scalar/Swagger routes themselves) as well as user-defined
447
+ * probe `httpGet` paths.
448
+ * @default true
449
+ */
450
+ excludeInternalRoutes?: boolean;
451
+ /**
452
+ * Additional route prefixes to strip from the served OpenAPI document.
453
+ * Entries are matched as path prefixes (e.g. `/internal` also removes `/internal/status`).
454
+ * @default []
455
+ */
456
+ excludeRoutes?: string[];
457
+ }
418
458
  interface C8yNitroModuleOptions {
419
459
  dev?: C8yDevOptions;
420
460
  manifest?: C8YManifestOptions;
421
461
  apiClient?: C8YAPIClientOptions;
422
462
  zip?: C8YZipOptions;
463
+ /**
464
+ * Controls the Dockerfile generated for the microservice image
465
+ * (base image, extra instructions).
466
+ */
467
+ docker?: C8yDockerOptions;
423
468
  cache?: C8yCacheOptions;
469
+ /**
470
+ * Controls how the OpenAPI document served by Nitro (`experimental.openAPI`)
471
+ * is transformed for Cumulocity microservices.
472
+ */
473
+ openapi?: C8yOpenAPIOptions;
424
474
  /**
425
475
  * Adds a debug route for invalidating already-created tenant option caches.
426
476
  * Exposes `GET /_c8y_nitro/invalidate-tenant-options`.
@@ -444,4 +494,4 @@ interface C8yNitroModuleOptions {
444
494
  skipBootstrap?: boolean;
445
495
  }
446
496
  //#endregion
447
- export { C8YTenantOptionKeysCacheConfig$1 as a, C8YZipOptions as c, C8YAPIClientOptions as d, C8YTenantOptionKey$1 as i, C8YManifest as l, C8yNitroModuleOptions as n, C8YRoles$1 as o, C8YSettingsCategory$1 as r, C8yCacheOptions as s, C8yDevOptions as t, C8YManifestOptions as u };
497
+ export { C8YTenantOptionKey$1 as a, C8yDockerOptions as c, C8YManifest as d, C8YManifestOptions as f, C8YSettingsCategory$1 as i, C8yCacheOptions as l, C8yNitroModuleOptions as n, C8YTenantOptionKeysCacheConfig$1 as o, C8YAPIClientOptions as p, C8yOpenAPIOptions as r, C8YRoles$1 as s, C8yDevOptions as t, C8YZipOptions as u };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as C8yNitroModuleOptions } from "./index-ByDSMEvU.mjs";
1
+ import { n as C8yNitroModuleOptions } from "./index-vu4xaFkz.mjs";
2
2
  import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
3
3
  import { NitroModule } from "nitro/types";
4
4
  //#region src/index.d.ts
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { _ as GENERATED_READINESS_ROUTE, a as findMicroserviceByName, d as updateMicroservice, 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-BFjK6AYb.mjs";
2
2
  import { t as writeBootstrapCredentials } from "./env-file-BrOTmVjA.mjs";
3
- import { n as name } from "./package-BhFLRPOb.mjs";
3
+ import { n as name } from "./package-D5xRshsx.mjs";
4
4
  import { basename, dirname, join, relative } from "node:path";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { x } from "tinyexec";
@@ -220,14 +220,15 @@ async function writeAPIClient(nitro, options) {
220
220
  /**
221
221
  * Generate the Dockerfile content for a Nitro build
222
222
  * @param outputDirName - Name of the output directory (e.g., '.output')
223
+ * @param options - Dockerfile customization (base image, extra instructions)
223
224
  * @returns Dockerfile content as a string
224
225
  */
225
- function getDockerfileContent(outputDirName) {
226
- return `FROM node:24-slim AS runtime
226
+ function getDockerfileContent(outputDirName, options = {}) {
227
+ return `FROM ${options.baseImage ?? "node:24-slim"} AS runtime
227
228
 
228
229
  WORKDIR /app
229
230
 
230
- # Copy the Nitro build output
231
+ ${options.extraInstructions?.length ? `${options.extraInstructions.join("\n")}\n\n` : ""}# Copy the Nitro build output
231
232
  COPY ${outputDirName}/ ${outputDirName}/
232
233
 
233
234
  ENV NODE_ENV=production
@@ -248,12 +249,12 @@ async function checkDockerInstalled() {
248
249
  return false;
249
250
  }
250
251
  }
251
- async function writeDockerfile(outputDir) {
252
+ async function writeDockerfile(outputDir, options) {
252
253
  const outputDirName = basename(outputDir);
253
254
  const c8yDir = join(outputDir, "../.c8y");
254
255
  const dockerfilePath = join(c8yDir, "Dockerfile");
255
256
  await mkdir(c8yDir, { recursive: true });
256
- await writeFile(dockerfilePath, getDockerfileContent(outputDirName), "utf-8");
257
+ await writeFile(dockerfilePath, getDockerfileContent(outputDirName, options), "utf-8");
257
258
  return c8yDir;
258
259
  }
259
260
  async function buildDockerImage(nitro, c8yDir) {
@@ -302,12 +303,13 @@ async function saveDockerImageToTar(nitro, c8yDir, imageName) {
302
303
  /**
303
304
  * Create a Docker image from the Nitro build output
304
305
  * @param nitro Nitro instance
306
+ * @param options Dockerfile customization (base image, extra instructions)
305
307
  * @returns Path to the saved Docker image tar file
306
308
  */
307
- async function createDockerImage(nitro) {
309
+ async function createDockerImage(nitro, options = {}) {
308
310
  if (!await checkDockerInstalled()) throw new Error("Docker is not installed or not available in PATH. Please install Docker to build images.");
309
311
  nitro.logger.debug("Creating Docker image...");
310
- const c8yDir = await writeDockerfile(nitro.options.output.dir);
312
+ const c8yDir = await writeDockerfile(nitro.options.output.dir, options);
311
313
  const imageTarPath = await saveDockerImageToTar(nitro, c8yDir, await buildDockerImage(nitro, c8yDir));
312
314
  nitro.logger.debug("Docker image creation complete.");
313
315
  return imageTarPath;
@@ -337,12 +339,12 @@ function formatBytes(bytes) {
337
339
  function resolveZipOutputPath(rootDir, options, manifest) {
338
340
  return join$1(join$1(rootDir, options.outputDir ?? "./"), typeof options.name === "function" ? options.name(manifest.name, manifest.version) : options.name ?? `${manifest.name}-${manifest.version}.zip`);
339
341
  }
340
- async function createC8yZip(nitro, options = {}) {
342
+ async function createC8yZip(nitro, options = {}, dockerOptions = {}) {
341
343
  const startTime = Date.now();
342
344
  const spinnerName = "c8y-zip";
343
345
  spinnies.add(spinnerName, { text: "Creating Dockerfile..." });
344
346
  spinnies.update(spinnerName, { text: "Building Docker image..." });
345
- const imageTarPath = await createDockerImage(nitro);
347
+ const imageTarPath = await createDockerImage(nitro, dockerOptions);
346
348
  spinnies.update(spinnerName, { text: "Creating manifest..." });
347
349
  const manifest = await createC8yManifestFromNitro(nitro);
348
350
  spinnies.update(spinnerName, { text: "Reading image.tar..." });
@@ -421,6 +423,9 @@ async function setupRuntimeConfig(nitro, options) {
421
423
  nitro.options.runtimeConfig.c8yTenantOptionsPerKeyTTL = options.cache?.tenantOptions ?? {};
422
424
  const manifest = await createC8yManifest(nitro.options.rootDir, nitro.options, nitro.logger);
423
425
  nitro.options.runtimeConfig.c8ySettingsCategory = options.manifest?.settingsCategory ?? manifest.contextPath ?? manifest.name;
426
+ const excludeInternalRoutes = options.openapi?.excludeInternalRoutes ?? true;
427
+ const probePaths = [options.manifest?.livenessProbe?.httpGet?.path, options.manifest?.readinessProbe?.httpGet?.path].filter((path) => Boolean(path));
428
+ nitro.options.runtimeConfig.c8yOpenApiExcludeRoutes = [...excludeInternalRoutes ? ["/_", ...probePaths] : [], ...options.openapi?.excludeRoutes ?? []];
424
429
  }
425
430
  //#endregion
426
431
  //#region src/module/registerRuntime.ts
@@ -460,6 +465,15 @@ function registerRuntime(nitro, options = {}) {
460
465
  handler,
461
466
  middleware: true
462
467
  })));
468
+ if (Boolean(nitro.options.experimental?.openAPI && (isNitroDev || nitro.options.openAPI?.production))) if (!isNitroDev && nitro.options.openAPI?.production === "prerender") nitro.logger.warn("c8y-nitro cannot transform a prerendered OpenAPI document (internal routes stay listed and the server URL is baked in at build time). Use `openAPI: { production: true }` to serve the document at runtime instead.");
469
+ else {
470
+ nitro.options.handlers.push({
471
+ route: "/**",
472
+ handler: join$1(thisFilePath, "./runtime/handlers/openapi"),
473
+ middleware: true
474
+ });
475
+ nitro.logger.debug(`Registered OpenAPI transform middleware for ${nitro.options.openAPI?.route || "/_openapi.json"}`);
476
+ }
463
477
  /**
464
478
  * Handlers (can't be auto scanned as they need methods etc)
465
479
  */
@@ -747,7 +761,7 @@ function c8y() {
747
761
  }
748
762
  });
749
763
  nitro.hooks.hook("close", async () => {
750
- if (nitro.options.preset !== "nitro-dev") await createC8yZip(nitro, options.zip);
764
+ if (nitro.options.preset !== "nitro-dev") await createC8yZip(nitro, options.zip, options.docker);
751
765
  });
752
766
  }
753
767
  };
@@ -0,0 +1,57 @@
1
+ import { createError } from "evlog";
2
+ import "evlog/nitro/v3";
3
+ //#region src/utils/logging.ts
4
+ /**
5
+ * Create a structured error for a Cumulocity microservice. Always prefer this
6
+ * over Nitro/h3's built-in `createError`: the returned error is captured in the
7
+ * wide log event **and** serialized into the JSON response under a `data` key.
8
+ *
9
+ * ## What the caller sees vs what stays on the server
10
+ *
11
+ * Every field **except `internal`** is sent to the client in the HTTP response.
12
+ * `internal` is stripped from the response and only ever appears in your logs.
13
+ *
14
+ * | Field | Sent to client? | Purpose |
15
+ * | ---------- | :-------------: | ------------------------------------------------------------- |
16
+ * | `message` | yes | Short, safe summary of what went wrong. |
17
+ * | `status` | yes | HTTP status code (default `500`). |
18
+ * | `code` | yes | Stable machine-readable id (e.g. `'PAYMENT_DECLINED'`). |
19
+ * | `why` | yes | Human-readable cause — safe to expose, no secrets. |
20
+ * | `fix` | yes | What the caller can do about it. |
21
+ * | `link` | yes | Docs URL with more detail. |
22
+ * | `cause` | no (logged) | The original `Error` you caught. |
23
+ * | `internal` | no (logged) | Backend-only diagnostics — never reaches the client. |
24
+ *
25
+ * ## Do NOT leak internal system errors
26
+ *
27
+ * Put anything sensitive or diagnostic (raw Cumulocity core responses, DB
28
+ * errors, tokens, tenant internals) in `internal`, **not** in `message`/`why`.
29
+ * `internal` is logged for debugging but never serialized to the response.
30
+ *
31
+ * @example
32
+ * // User-facing error — every field here is visible to the caller:
33
+ * throw createError({
34
+ * code: 'PAYMENT_DECLINED',
35
+ * message: 'Payment failed',
36
+ * status: 402,
37
+ * why: 'Card declined by issuer',
38
+ * fix: 'Try a different payment method',
39
+ * link: 'https://docs.example.com/payments/declined',
40
+ * })
41
+ *
42
+ * @example
43
+ * // Wrapping an upstream failure — keep the raw payload server-side only:
44
+ * catch (cause) {
45
+ * throw createError({
46
+ * status: 502,
47
+ * message: 'Upstream request failed', // safe, generic — what the caller sees
48
+ * cause: cause as Error,
49
+ * internal: { upstreamStatus: cause?.res?.status, upstreamBody: cause?.data },
50
+ * })
51
+ * }
52
+ *
53
+ * @see https://www.evlog.dev
54
+ */
55
+ const createError$1 = createError;
56
+ //#endregion
57
+ export { createError$1 as t };
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "c8y-nitro";
3
- var version = "0.7.0";
3
+ var version = "0.7.1";
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,222 +1,9 @@
1
- import { t as createError } from "../../logging-DYnYICoX.mjs";
1
+ import { t as createError } from "../../logging-BUYT8Z2V.mjs";
2
+ import { t as useDeployedTenantClient } from "../../client-DxmbyZju.mjs";
2
3
  import { c8yTenantOptionKeys } from "c8y-nitro/runtime";
3
- import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
4
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
5
  import { useRuntimeConfig } from "nitro/runtime-config";
9
- import { useNitroHooks } from "nitro/app";
10
6
  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
220
7
  //#region src/utils/internal/tenantOptionFetchers.ts
221
8
  /**
222
9
  * Internal storage for cached option fetchers, keyed by
@@ -0,0 +1,56 @@
1
+ import { n as getUserTenantDomain } from "../../client-DxmbyZju.mjs";
2
+ import { c8yManifest } from "c8y-nitro/runtime";
3
+ import { useRuntimeConfig } from "nitro/runtime-config";
4
+ import { getRequestURL } from "nitro/h3";
5
+ import { defineMiddleware } from "nitro";
6
+ //#region src/module/runtime/handlers/openapi.ts
7
+ function isOpenAPIDocument(value) {
8
+ return Boolean(value) && typeof value === "object" && "openapi" in value;
9
+ }
10
+ function joinURLSegments(origin, ...segments) {
11
+ const path = segments.map((segment) => segment?.replace(/^\/+|\/+$/g, "") ?? "").filter((segment) => segment.length > 0).join("/");
12
+ return path ? `${origin}/${path}` : origin;
13
+ }
14
+ async function resolveServer(event) {
15
+ const baseURL = useRuntimeConfig().app?.baseURL;
16
+ const contextPath = c8yManifest.contextPath ?? c8yManifest.name;
17
+ if (!import.meta.dev) try {
18
+ const domain = await getUserTenantDomain(event);
19
+ if (domain) return {
20
+ url: joinURLSegments(`https://${domain}`, "service", contextPath, baseURL),
21
+ description: "Cumulocity microservice endpoint"
22
+ };
23
+ } catch {}
24
+ const requestUrl = getRequestURL(event, {
25
+ xForwardedHost: true,
26
+ xForwardedProto: true
27
+ });
28
+ if (event.req.headers.has("x-forwarded-host")) return {
29
+ url: joinURLSegments(requestUrl.origin, "service", contextPath, baseURL),
30
+ description: "Cumulocity microservice endpoint"
31
+ };
32
+ return {
33
+ url: joinURLSegments(requestUrl.origin, baseURL),
34
+ description: "Direct server endpoint"
35
+ };
36
+ }
37
+ /**
38
+ * Transforms the OpenAPI document generated by Nitro for Cumulocity microservices:
39
+ * strips internal routes (probes, `/_c8y_nitro/**`, the spec/UI routes themselves)
40
+ * and rewrites the advertised server URL to the public tenant endpoint instead of
41
+ * the internal (e.g. cluster-local) host the service listens on.
42
+ */
43
+ var openapi_default = defineMiddleware(async (event, next) => {
44
+ const runtimeConfig = useRuntimeConfig();
45
+ const specRoute = runtimeConfig.nitro?.openAPI?.route || "/_openapi.json";
46
+ const baseURL = (runtimeConfig.app?.baseURL ?? "/").replace(/\/+$/, "");
47
+ if (event.url.pathname !== `${baseURL}${specRoute}`) return next();
48
+ const body = await next();
49
+ if (!isOpenAPIDocument(body)) return body;
50
+ const excludedPrefixes = runtimeConfig.c8yOpenApiExcludeRoutes ?? [];
51
+ if (body.paths && excludedPrefixes.length > 0) body.paths = Object.fromEntries(Object.entries(body.paths).filter(([path]) => !excludedPrefixes.some((prefix) => path.startsWith(prefix))));
52
+ body.servers = [await resolveServer(event)];
53
+ return body;
54
+ });
55
+ //#endregion
56
+ export { openapi_default as default };
@@ -1,4 +1,4 @@
1
- import { t as createError } from "../../logging-DYnYICoX.mjs";
1
+ import { t as createError } from "../../logging-BUYT8Z2V.mjs";
2
2
  import process from "node:process";
3
3
  import { defineMiddleware } from "nitro";
4
4
  //#region src/module/runtime/middlewares/c8y-client.ts
package/dist/types.d.mts CHANGED
@@ -1,3 +1,3 @@
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";
1
+ import { a as C8YTenantOptionKey, c as C8yDockerOptions, d as C8YManifest, f as C8YManifestOptions, i as C8YSettingsCategory, l as C8yCacheOptions, n as C8yNitroModuleOptions, o as C8YTenantOptionKeysCacheConfig, p as C8YAPIClientOptions, r as C8yOpenAPIOptions, s as C8YRoles, t as C8yDevOptions, u as C8YZipOptions } from "./index-vu4xaFkz.mjs";
2
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 };
3
+ export { C8YAPIClientOptions, type C8YManifest, type C8YManifestOptions, C8YRoles, C8YSettingsCategory, C8YTenantOptionKey, C8YTenantOptionKeysCacheConfig, type C8YZipOptions, type C8yCacheOptions, C8yDevOptions, type C8yDockerOptions, C8yNitroModuleOptions, C8yOpenAPIOptions, type TenantCredentials };
package/dist/utils.d.mts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { t as TenantCredentials } from "./credentials-eKnJ7m9Y.mjs";
2
- import { useLogger } from "evlog/nitro/v3";
3
2
  import { Client, ICredentials, ICurrentUser } from "@c8y/client";
4
- import { createError, createLogger } from "evlog";
5
- import { EventHandler, H3Event } from "nitro/h3";
3
+ import { createError as createError$1, createLogger } from "evlog";
4
+ import { EventHandler, H3Event, HTTPEvent } from "nitro/h3";
6
5
  import { ServerRequest, TaskContext, TaskPayload } from "nitro/types";
7
6
  import { C8YRoles, C8YSettingsCategory, C8YTenantOptionKey } from "c8y-nitro/types";
8
7
  //#region src/utils/client.d.ts
@@ -425,6 +424,84 @@ interface UseTenantOptions {
425
424
  */
426
425
  declare const useTenantOptions: UseTenantOptions;
427
426
  //#endregion
427
+ //#region src/utils/logging.d.ts
428
+ /**
429
+ * Returns the request-scoped wide-event logger for the current request.
430
+ *
431
+ * Accepts **either** an `H3Event` (as received by a route handler) **or** the
432
+ * `ServerRequest` returned by Nitro's `useRequest()` — both carry the same
433
+ * request context, so `useLogger(event)` and `useLogger(useRequest())` are
434
+ * equivalent.
435
+ *
436
+ * evlog's own `useLogger` only accepts an `H3Event` and reads the logger from
437
+ * `event.req.context.log`; `useRequest()` returns that `ServerRequest` directly
438
+ * (no `.req`), which would throw. This wrapper normalizes both shapes to the
439
+ * `{ req }` event evlog expects, restoring the accept-both behavior.
440
+ *
441
+ * @param eventOrRequest - The current `H3Event` or `ServerRequest` (`useRequest()`).
442
+ * @param service - Optional service name to override the default.
443
+ * @example
444
+ * // In a route handler:
445
+ * const log = useLogger(event)
446
+ *
447
+ * // Deeper in the call stack with experimental.asyncContext enabled:
448
+ * import { useRequest } from 'nitro/context'
449
+ * const log = useLogger(useRequest())
450
+ */
451
+ declare function useLogger<T extends object = Record<string, unknown>>(eventOrRequest: HTTPEvent | ServerRequest, service?: string): import("evlog").AuditableLogger<T>;
452
+ /**
453
+ * Create a structured error for a Cumulocity microservice. Always prefer this
454
+ * over Nitro/h3's built-in `createError`: the returned error is captured in the
455
+ * wide log event **and** serialized into the JSON response under a `data` key.
456
+ *
457
+ * ## What the caller sees vs what stays on the server
458
+ *
459
+ * Every field **except `internal`** is sent to the client in the HTTP response.
460
+ * `internal` is stripped from the response and only ever appears in your logs.
461
+ *
462
+ * | Field | Sent to client? | Purpose |
463
+ * | ---------- | :-------------: | ------------------------------------------------------------- |
464
+ * | `message` | yes | Short, safe summary of what went wrong. |
465
+ * | `status` | yes | HTTP status code (default `500`). |
466
+ * | `code` | yes | Stable machine-readable id (e.g. `'PAYMENT_DECLINED'`). |
467
+ * | `why` | yes | Human-readable cause — safe to expose, no secrets. |
468
+ * | `fix` | yes | What the caller can do about it. |
469
+ * | `link` | yes | Docs URL with more detail. |
470
+ * | `cause` | no (logged) | The original `Error` you caught. |
471
+ * | `internal` | no (logged) | Backend-only diagnostics — never reaches the client. |
472
+ *
473
+ * ## Do NOT leak internal system errors
474
+ *
475
+ * Put anything sensitive or diagnostic (raw Cumulocity core responses, DB
476
+ * errors, tokens, tenant internals) in `internal`, **not** in `message`/`why`.
477
+ * `internal` is logged for debugging but never serialized to the response.
478
+ *
479
+ * @example
480
+ * // User-facing error — every field here is visible to the caller:
481
+ * throw createError({
482
+ * code: 'PAYMENT_DECLINED',
483
+ * message: 'Payment failed',
484
+ * status: 402,
485
+ * why: 'Card declined by issuer',
486
+ * fix: 'Try a different payment method',
487
+ * link: 'https://docs.example.com/payments/declined',
488
+ * })
489
+ *
490
+ * @example
491
+ * // Wrapping an upstream failure — keep the raw payload server-side only:
492
+ * catch (cause) {
493
+ * throw createError({
494
+ * status: 502,
495
+ * message: 'Upstream request failed', // safe, generic — what the caller sees
496
+ * cause: cause as Error,
497
+ * internal: { upstreamStatus: cause?.res?.status, upstreamBody: cause?.data },
498
+ * })
499
+ * }
500
+ *
501
+ * @see https://www.evlog.dev
502
+ */
503
+ declare const createError: typeof createError$1;
504
+ //#endregion
428
505
  //#region src/utils/schedule.d.ts
429
506
  type ScheduledTaskPayload = TaskPayload;
430
507
  type ScheduledTaskContext = TaskContext;
package/dist/utils.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import process from "node:process";
4
- import { useLogger } from "evlog/nitro/v3";
4
+ import { useLogger as useLogger$1 } from "evlog/nitro/v3";
5
5
  import { BasicAuth, Client, MicroserviceClientRequestAuth } from "@c8y/client";
6
6
  import { defineCachedFunction } from "nitro/cache";
7
- import { createError, createLogger } from "evlog";
7
+ import { createError as createError$1, createLogger } from "evlog";
8
8
  import { useRuntimeConfig } from "nitro/runtime-config";
9
9
  import { useNitroHooks } from "nitro/app";
10
10
  import { defineHandler } from "nitro/h3";
@@ -83,6 +83,95 @@ const getCurrentUserTenantId = defineCachedFunction(async (requestOrEvent) => {
83
83
  getKey: (requestOrEvent) => createCurrentUserTenantCacheKey(requestOrEvent),
84
84
  shouldBypassCache: (requestOrEvent) => !getCurrentUserTenantCacheKeyMaterial(requestOrEvent)
85
85
  });
86
+ defineCachedFunction(async (requestOrEvent, _tenantId) => {
87
+ return (await useUserClient(requestOrEvent).tenant.current()).data.domainName;
88
+ }, {
89
+ maxAge: 86400,
90
+ name: "_c8y_nitro_get_user_tenant_domain",
91
+ group: "c8y_nitro",
92
+ swr: false,
93
+ getKey: (_requestOrEvent, tenantId) => tenantId
94
+ });
95
+ //#endregion
96
+ //#region src/utils/logging.ts
97
+ /**
98
+ * Returns the request-scoped wide-event logger for the current request.
99
+ *
100
+ * Accepts **either** an `H3Event` (as received by a route handler) **or** the
101
+ * `ServerRequest` returned by Nitro's `useRequest()` — both carry the same
102
+ * request context, so `useLogger(event)` and `useLogger(useRequest())` are
103
+ * equivalent.
104
+ *
105
+ * evlog's own `useLogger` only accepts an `H3Event` and reads the logger from
106
+ * `event.req.context.log`; `useRequest()` returns that `ServerRequest` directly
107
+ * (no `.req`), which would throw. This wrapper normalizes both shapes to the
108
+ * `{ req }` event evlog expects, restoring the accept-both behavior.
109
+ *
110
+ * @param eventOrRequest - The current `H3Event` or `ServerRequest` (`useRequest()`).
111
+ * @param service - Optional service name to override the default.
112
+ * @example
113
+ * // In a route handler:
114
+ * const log = useLogger(event)
115
+ *
116
+ * // Deeper in the call stack with experimental.asyncContext enabled:
117
+ * import { useRequest } from 'nitro/context'
118
+ * const log = useLogger(useRequest())
119
+ */
120
+ function useLogger(eventOrRequest, service) {
121
+ return useLogger$1("req" in eventOrRequest ? eventOrRequest : { req: eventOrRequest }, service);
122
+ }
123
+ /**
124
+ * Create a structured error for a Cumulocity microservice. Always prefer this
125
+ * over Nitro/h3's built-in `createError`: the returned error is captured in the
126
+ * wide log event **and** serialized into the JSON response under a `data` key.
127
+ *
128
+ * ## What the caller sees vs what stays on the server
129
+ *
130
+ * Every field **except `internal`** is sent to the client in the HTTP response.
131
+ * `internal` is stripped from the response and only ever appears in your logs.
132
+ *
133
+ * | Field | Sent to client? | Purpose |
134
+ * | ---------- | :-------------: | ------------------------------------------------------------- |
135
+ * | `message` | yes | Short, safe summary of what went wrong. |
136
+ * | `status` | yes | HTTP status code (default `500`). |
137
+ * | `code` | yes | Stable machine-readable id (e.g. `'PAYMENT_DECLINED'`). |
138
+ * | `why` | yes | Human-readable cause — safe to expose, no secrets. |
139
+ * | `fix` | yes | What the caller can do about it. |
140
+ * | `link` | yes | Docs URL with more detail. |
141
+ * | `cause` | no (logged) | The original `Error` you caught. |
142
+ * | `internal` | no (logged) | Backend-only diagnostics — never reaches the client. |
143
+ *
144
+ * ## Do NOT leak internal system errors
145
+ *
146
+ * Put anything sensitive or diagnostic (raw Cumulocity core responses, DB
147
+ * errors, tokens, tenant internals) in `internal`, **not** in `message`/`why`.
148
+ * `internal` is logged for debugging but never serialized to the response.
149
+ *
150
+ * @example
151
+ * // User-facing error — every field here is visible to the caller:
152
+ * throw createError({
153
+ * code: 'PAYMENT_DECLINED',
154
+ * message: 'Payment failed',
155
+ * status: 402,
156
+ * why: 'Card declined by issuer',
157
+ * fix: 'Try a different payment method',
158
+ * link: 'https://docs.example.com/payments/declined',
159
+ * })
160
+ *
161
+ * @example
162
+ * // Wrapping an upstream failure — keep the raw payload server-side only:
163
+ * catch (cause) {
164
+ * throw createError({
165
+ * status: 502,
166
+ * message: 'Upstream request failed', // safe, generic — what the caller sees
167
+ * cause: cause as Error,
168
+ * internal: { upstreamStatus: cause?.res?.status, upstreamBody: cause?.data },
169
+ * })
170
+ * }
171
+ *
172
+ * @see https://www.evlog.dev
173
+ */
174
+ const createError = createError$1;
86
175
  //#endregion
87
176
  //#region src/utils/credentials.ts
88
177
  let prevCredentials = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8y-nitro",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Lightning fast Cumulocity IoT microservice development powered by Nitro",
6
6
  "keywords": [
@@ -1,3 +0,0 @@
1
- import "evlog/nitro/v3";
2
- import { createError } from "evlog";
3
- export { createError as t };