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.
- package/dist/cli/index.mjs +1 -1
- package/dist/client-DxmbyZju.mjs +236 -0
- package/dist/{index-ByDSMEvU.d.mts → index-vu4xaFkz.d.mts} +51 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +25 -11
- package/dist/logging-BUYT8Z2V.mjs +57 -0
- package/dist/{package-BhFLRPOb.mjs → package-D5xRshsx.mjs} +1 -1
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +2 -215
- package/dist/runtime/handlers/openapi.mjs +56 -0
- package/dist/runtime/middlewares/c8y-client.mjs +1 -1
- package/dist/types.d.mts +2 -2
- package/dist/utils.d.mts +80 -3
- package/dist/utils.mjs +91 -2
- package/package.json +1 -1
- package/dist/logging-DYnYICoX.mjs +0 -3
package/dist/cli/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as name, r as version, t as description } from "../package-
|
|
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 {
|
|
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-
|
|
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-
|
|
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,222 +1,9 @@
|
|
|
1
|
-
import { t as createError } from "../../logging-
|
|
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 };
|
package/dist/types.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as
|
|
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