c8y-nitro 0.7.0 → 0.7.2
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/autoBootstrap-Drk7ltXK.mjs +212 -0
- package/dist/{bootstrap-ObaEF7rN.mjs → bootstrap-C1Qly0J_.mjs} +2 -2
- package/dist/{c8y-api-BFjK6AYb.mjs → c8y-api-DscUs-Kd.mjs} +22 -18
- package/dist/cli/index.mjs +4 -4
- package/dist/client-D0Tdq7H1.mjs +280 -0
- package/dist/{index-ByDSMEvU.d.mts → index-vu4xaFkz.d.mts} +51 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +44 -183
- package/dist/logging-BUYT8Z2V.mjs +57 -0
- package/dist/options-CmLpZ3Qf.mjs +179 -0
- package/dist/{package-BhFLRPOb.mjs → package-BHOzdhHp.mjs} +1 -1
- package/dist/{roles-B58Fguop.mjs → roles-B-DNuScZ.mjs} +1 -1
- package/dist/runtime/handlers/invalidateTenantOptions.mjs +2 -215
- package/dist/runtime/handlers/openapi.mjs +57 -0
- package/dist/runtime/handlers/rewriteRequestUrl.mjs +38 -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/options-B7_yb_wr.mjs +0 -147
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-DscUs-Kd.mjs";
|
|
2
|
+
import { t as writeBootstrapCredentials } from "./env-file-BrOTmVjA.mjs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
//#region src/module/manifestCache.ts
|
|
8
|
+
/**
|
|
9
|
+
* Directory (inside the project's `node_modules`) where c8y-nitro persists
|
|
10
|
+
* local dev state such as the last-synced manifest hash. Lives in
|
|
11
|
+
* `node_modules` so it is naturally gitignored and scoped per-checkout.
|
|
12
|
+
*/
|
|
13
|
+
const CACHE_DIR_SEGMENTS = ["node_modules", ".c8y-nitro"];
|
|
14
|
+
const CACHE_FILE_NAME = "bootstrap-state.json";
|
|
15
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
16
|
+
function getCacheDir(rootDir) {
|
|
17
|
+
return join(rootDir, ...CACHE_DIR_SEGMENTS);
|
|
18
|
+
}
|
|
19
|
+
function getCacheFile(rootDir) {
|
|
20
|
+
return join(getCacheDir(rootDir), CACHE_FILE_NAME);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Compute a hash over the manifest and the deployment target it will be synced
|
|
24
|
+
* to. Folding the target in means switching tenant/baseURL invalidates the
|
|
25
|
+
* cached hash even if the manifest itself is unchanged.
|
|
26
|
+
*
|
|
27
|
+
* @param manifest - The generated Cumulocity manifest
|
|
28
|
+
* @param target - The deployment target identity
|
|
29
|
+
*/
|
|
30
|
+
function hashManifest(manifest, target) {
|
|
31
|
+
const payload = JSON.stringify({
|
|
32
|
+
manifest,
|
|
33
|
+
target
|
|
34
|
+
});
|
|
35
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the persisted bootstrap state, or `null` when absent, unreadable, or
|
|
39
|
+
* from an incompatible schema version.
|
|
40
|
+
* @param rootDir - Project root directory (containing `node_modules`)
|
|
41
|
+
*/
|
|
42
|
+
async function readManifestCache(rootDir) {
|
|
43
|
+
try {
|
|
44
|
+
const content = await readFile(getCacheFile(rootDir), "utf-8");
|
|
45
|
+
const state = JSON.parse(content);
|
|
46
|
+
if (state.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
47
|
+
return state;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Persist the bootstrap state for future dev restarts.
|
|
54
|
+
* @param rootDir - Project root directory (containing `node_modules`)
|
|
55
|
+
* @param state - The state to persist (schema version is filled in automatically)
|
|
56
|
+
*/
|
|
57
|
+
async function writeManifestCache(rootDir, state) {
|
|
58
|
+
await mkdir(getCacheDir(rootDir), { recursive: true });
|
|
59
|
+
const payload = {
|
|
60
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
61
|
+
...state
|
|
62
|
+
};
|
|
63
|
+
await writeFile(getCacheFile(rootDir), JSON.stringify(payload, null, 2), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/module/autoBootstrap.ts
|
|
67
|
+
const BOOTSTRAP_ENV_VARS = [
|
|
68
|
+
"C8Y_BOOTSTRAP_TENANT",
|
|
69
|
+
"C8Y_BOOTSTRAP_USER",
|
|
70
|
+
"C8Y_BOOTSTRAP_PASSWORD"
|
|
71
|
+
];
|
|
72
|
+
const DEV_ENV_VARS = [
|
|
73
|
+
"C8Y_BASEURL",
|
|
74
|
+
"C8Y_DEVELOPMENT_TENANT",
|
|
75
|
+
"C8Y_DEVELOPMENT_USER",
|
|
76
|
+
"C8Y_DEVELOPMENT_PASSWORD"
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* Reads bootstrap credentials from an env object (e.g. a loaded `.env` file or
|
|
80
|
+
* `process.env`). Returns `undefined` unless all three variables are present.
|
|
81
|
+
* @param env - Environment variables object
|
|
82
|
+
*/
|
|
83
|
+
function readBootstrapCredentialsFromEnv(env) {
|
|
84
|
+
const tenant = env.C8Y_BOOTSTRAP_TENANT;
|
|
85
|
+
const name = env.C8Y_BOOTSTRAP_USER;
|
|
86
|
+
const password = env.C8Y_BOOTSTRAP_PASSWORD;
|
|
87
|
+
if (!tenant || !name || !password) return;
|
|
88
|
+
return {
|
|
89
|
+
tenant,
|
|
90
|
+
name,
|
|
91
|
+
password
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Verifies the microservice on the development tenant and keeps it in sync with
|
|
96
|
+
* the local manifest, bootstrapping (or re-bootstrapping) when needed.
|
|
97
|
+
*
|
|
98
|
+
* Unlike a purely env-var-based check, this always verifies against the tenant,
|
|
99
|
+
* so it also handles:
|
|
100
|
+
* - the manifest changing between runs (auto-updates a placeholder), and
|
|
101
|
+
* - the application being deleted on the tenant while stale bootstrap env vars
|
|
102
|
+
* still linger locally (re-creates it).
|
|
103
|
+
*
|
|
104
|
+
* A previously *deployed* microservice (one with an active version) is never
|
|
105
|
+
* overwritten automatically - we only warn about the drift.
|
|
106
|
+
*
|
|
107
|
+
* @param options - See {@link SyncBootstrappedMicroserviceOptions}
|
|
108
|
+
* @returns The bootstrap credentials for the microservice (freshly fetched when
|
|
109
|
+
* they were refreshed, otherwise the ones already present in `env`)
|
|
110
|
+
*/
|
|
111
|
+
async function syncBootstrappedMicroservice(options) {
|
|
112
|
+
const { rootDir, baseUrl, developmentTenant, authHeader, manifest, logger } = options;
|
|
113
|
+
const env = options.env ?? process.env;
|
|
114
|
+
const target = {
|
|
115
|
+
baseUrl,
|
|
116
|
+
developmentTenant,
|
|
117
|
+
name: manifest.name
|
|
118
|
+
};
|
|
119
|
+
const currentHash = hashManifest(manifest, target);
|
|
120
|
+
const existingApp = await findMicroserviceByName(baseUrl, manifest.name, authHeader);
|
|
121
|
+
if (!existingApp) {
|
|
122
|
+
const createdApp = await createMicroservice(baseUrl, manifest, authHeader);
|
|
123
|
+
logger.debug(`Microservice "${manifest.name}" created (ID: ${createdApp.id})`);
|
|
124
|
+
const credentials = await finalizeBootstrap({
|
|
125
|
+
rootDir,
|
|
126
|
+
logger,
|
|
127
|
+
baseUrl,
|
|
128
|
+
developmentTenant,
|
|
129
|
+
appId: createdApp.id,
|
|
130
|
+
authHeader,
|
|
131
|
+
target,
|
|
132
|
+
manifestHash: currentHash,
|
|
133
|
+
forceCredentials: true
|
|
134
|
+
});
|
|
135
|
+
logger.success(`Microservice "${manifest.name}" created on the development tenant.`);
|
|
136
|
+
return credentials;
|
|
137
|
+
}
|
|
138
|
+
const appId = existingApp.id;
|
|
139
|
+
const isRealService = Boolean(existingApp.activeVersionId);
|
|
140
|
+
const cache = await readManifestCache(rootDir);
|
|
141
|
+
if (!cache || cache.manifestHash !== currentHash || cache.appId !== appId) if (isRealService) logger.warn(`Local manifest for "${manifest.name}" differs from the deployed microservice on the development tenant. The running service was left untouched. Redeploy or run \`npx c8y-nitro bootstrap\` to update it explicitly.`);
|
|
142
|
+
else {
|
|
143
|
+
await updateMicroservice(baseUrl, appId, manifest, authHeader);
|
|
144
|
+
logger.success(`Updated microservice "${manifest.name}" with changed manifest.`);
|
|
145
|
+
}
|
|
146
|
+
const hasBootstrapCreds = BOOTSTRAP_ENV_VARS.every((v) => env[v]);
|
|
147
|
+
const appChanged = cache?.appId !== appId;
|
|
148
|
+
return await finalizeBootstrap({
|
|
149
|
+
rootDir,
|
|
150
|
+
logger,
|
|
151
|
+
baseUrl,
|
|
152
|
+
developmentTenant,
|
|
153
|
+
appId,
|
|
154
|
+
authHeader,
|
|
155
|
+
target,
|
|
156
|
+
manifestHash: currentHash,
|
|
157
|
+
forceCredentials: !hasBootstrapCreds || appChanged
|
|
158
|
+
}) ?? readBootstrapCredentialsFromEnv(env);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Verifies the microservice on the development tenant during dev startup.
|
|
162
|
+
* Runs silently unless a bootstrap/sync was performed or an error occurs.
|
|
163
|
+
* @param nitro - Nitro instance
|
|
164
|
+
*/
|
|
165
|
+
async function autoBootstrap(nitro) {
|
|
166
|
+
try {
|
|
167
|
+
if (DEV_ENV_VARS.filter((v) => !process.env[v]).length > 0) return;
|
|
168
|
+
const baseUrl = process.env.C8Y_BASEURL.endsWith("/") ? process.env.C8Y_BASEURL.slice(0, -1) : process.env.C8Y_BASEURL;
|
|
169
|
+
const authHeader = createBasicAuthHeader(process.env.C8Y_DEVELOPMENT_TENANT, process.env.C8Y_DEVELOPMENT_USER, process.env.C8Y_DEVELOPMENT_PASSWORD);
|
|
170
|
+
const manifest = await createC8yManifest(nitro.options.rootDir, nitro.options, nitro.logger);
|
|
171
|
+
await syncBootstrappedMicroservice({
|
|
172
|
+
rootDir: nitro.options.rootDir,
|
|
173
|
+
baseUrl,
|
|
174
|
+
developmentTenant: process.env.C8Y_DEVELOPMENT_TENANT,
|
|
175
|
+
authHeader,
|
|
176
|
+
manifest,
|
|
177
|
+
logger: nitro.logger
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
nitro.logger.warn("Auto-bootstrap failed:", error instanceof Error ? error.message : String(error));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Subscribe the tenant, optionally (re)write bootstrap credentials, and persist
|
|
185
|
+
* the synced state to the local cache.
|
|
186
|
+
* @param opts - Bootstrap finalization inputs
|
|
187
|
+
* @returns The freshly fetched credentials when `forceCredentials` was set
|
|
188
|
+
*/
|
|
189
|
+
async function finalizeBootstrap(opts) {
|
|
190
|
+
await subscribeToApplication(opts.baseUrl, opts.developmentTenant, opts.appId, opts.authHeader);
|
|
191
|
+
let credentials;
|
|
192
|
+
if (opts.forceCredentials) {
|
|
193
|
+
credentials = await getBootstrapCredentials(opts.baseUrl, opts.appId, opts.authHeader);
|
|
194
|
+
const envFileName = await writeBootstrapCredentials(opts.rootDir, {
|
|
195
|
+
C8Y_BOOTSTRAP_TENANT: credentials.tenant,
|
|
196
|
+
C8Y_BOOTSTRAP_USER: credentials.name,
|
|
197
|
+
C8Y_BOOTSTRAP_PASSWORD: credentials.password
|
|
198
|
+
});
|
|
199
|
+
process.env.C8Y_BOOTSTRAP_TENANT = credentials.tenant;
|
|
200
|
+
process.env.C8Y_BOOTSTRAP_USER = credentials.name;
|
|
201
|
+
process.env.C8Y_BOOTSTRAP_PASSWORD = credentials.password;
|
|
202
|
+
opts.logger.debug(`Bootstrap credentials written to ${envFileName}`);
|
|
203
|
+
}
|
|
204
|
+
await writeManifestCache(opts.rootDir, {
|
|
205
|
+
manifestHash: opts.manifestHash,
|
|
206
|
+
target: opts.target,
|
|
207
|
+
appId: opts.appId
|
|
208
|
+
});
|
|
209
|
+
return credentials;
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
export { readBootstrapCredentialsFromEnv as n, syncBootstrappedMicroservice as r, autoBootstrap as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-
|
|
1
|
+
import { a as findMicroserviceByName, d as updateMicroservice, l as subscribeToApplication, n as createBasicAuthHeader, o as getBootstrapCredentials, p as createC8yManifest, r as createMicroservice } from "./c8y-api-DscUs-Kd.mjs";
|
|
2
2
|
import { t as writeBootstrapCredentials } from "./env-file-BrOTmVjA.mjs";
|
|
3
3
|
import { n as validateBootstrapEnv, t as loadC8yConfig } from "./config-BVQHqlgV.mjs";
|
|
4
4
|
import { defineCommand, runCommand } from "citty";
|
|
@@ -52,7 +52,7 @@ var bootstrap_default = defineCommand({
|
|
|
52
52
|
});
|
|
53
53
|
consola.success(`Bootstrap credentials written to ${envFileName}`);
|
|
54
54
|
if (manifest.roles && manifest.roles.length > 0) {
|
|
55
|
-
if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-
|
|
55
|
+
if (await consola.prompt("Do you want to manage microservice roles for your development user?", { type: "confirm" })) await runCommand(await import("./roles-B-DNuScZ.mjs").then((r) => r.default), { rawArgs: [] });
|
|
56
56
|
}
|
|
57
57
|
consola.success("Bootstrap complete!");
|
|
58
58
|
}
|
|
@@ -283,14 +283,20 @@ async function unassignUserRole(baseUrl, tenantId, userId, roleId, authHeader) {
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
/**
|
|
286
|
-
*
|
|
286
|
+
* Fetches the current application's settings (the tenant options of the
|
|
287
|
+
* microservice's own settings category) via
|
|
288
|
+
* `GET /application/currentApplication/settings`.
|
|
289
|
+
*
|
|
290
|
+
* Must be called with microservice credentials (bootstrap or service user).
|
|
291
|
+
* Unlike the Options API — which returns `credentials.*` values encrypted for
|
|
292
|
+
* regular users and rejects the bootstrap user with `403` — this endpoint
|
|
293
|
+
* returns encrypted `credentials.*` options **decrypted**.
|
|
287
294
|
* @param baseUrl - The Cumulocity base URL
|
|
288
|
-
* @param
|
|
289
|
-
* @
|
|
290
|
-
* @returns Record of key-value pairs for the category
|
|
295
|
+
* @param authHeader - Basic Auth header of the bootstrap (or service) user
|
|
296
|
+
* @returns Flat key-value map of the microservice's settings
|
|
291
297
|
*/
|
|
292
|
-
async function
|
|
293
|
-
const url = `${baseUrl}/
|
|
298
|
+
async function getCurrentApplicationSettings(baseUrl, authHeader) {
|
|
299
|
+
const url = `${baseUrl}/application/currentApplication/settings`;
|
|
294
300
|
const response = await fetch(url, {
|
|
295
301
|
method: "GET",
|
|
296
302
|
headers: {
|
|
@@ -298,23 +304,21 @@ async function getTenantOptionsByCategory(baseUrl, category, authHeader) {
|
|
|
298
304
|
Accept: "application/json"
|
|
299
305
|
}
|
|
300
306
|
});
|
|
301
|
-
if (response.status === 404) return {};
|
|
302
307
|
if (!response.ok) {
|
|
303
308
|
const errorText = await response.text();
|
|
304
|
-
throw new Error(`Failed to get
|
|
309
|
+
throw new Error(`Failed to get current application settings: ${response.status} ${response.statusText}\n${errorText}`, { cause: response });
|
|
305
310
|
}
|
|
306
311
|
return await response.json();
|
|
307
312
|
}
|
|
308
313
|
/**
|
|
309
|
-
* Gets a specific
|
|
314
|
+
* Gets all tenant options for a specific category.
|
|
310
315
|
* @param baseUrl - The Cumulocity base URL
|
|
311
|
-
* @param category - The category
|
|
312
|
-
* @param key - The option key
|
|
316
|
+
* @param category - The category to fetch options for
|
|
313
317
|
* @param authHeader - The Basic Auth header
|
|
314
|
-
* @returns
|
|
318
|
+
* @returns Record of key-value pairs for the category
|
|
315
319
|
*/
|
|
316
|
-
async function
|
|
317
|
-
const url = `${baseUrl}/tenant/options/${encodeURIComponent(category)}
|
|
320
|
+
async function getTenantOptionsByCategory(baseUrl, category, authHeader) {
|
|
321
|
+
const url = `${baseUrl}/tenant/options/${encodeURIComponent(category)}`;
|
|
318
322
|
const response = await fetch(url, {
|
|
319
323
|
method: "GET",
|
|
320
324
|
headers: {
|
|
@@ -322,12 +326,12 @@ async function getTenantOption(baseUrl, category, key, authHeader) {
|
|
|
322
326
|
Accept: "application/json"
|
|
323
327
|
}
|
|
324
328
|
});
|
|
325
|
-
if (response.status === 404) return;
|
|
329
|
+
if (response.status === 404) return {};
|
|
326
330
|
if (!response.ok) {
|
|
327
331
|
const errorText = await response.text();
|
|
328
|
-
throw new Error(`Failed to get tenant
|
|
332
|
+
throw new Error(`Failed to get tenant options for category ${category}: ${response.status} ${response.statusText}\n${errorText}`, { cause: response });
|
|
329
333
|
}
|
|
330
|
-
return
|
|
334
|
+
return await response.json();
|
|
331
335
|
}
|
|
332
336
|
/**
|
|
333
337
|
* Creates a tenant option.
|
|
@@ -420,4 +424,4 @@ async function deleteTenantOption(baseUrl, category, key, authHeader) {
|
|
|
420
424
|
}
|
|
421
425
|
}
|
|
422
426
|
//#endregion
|
|
423
|
-
export { GENERATED_READINESS_ROUTE as _, findMicroserviceByName as a, getTenantOptionsByCategory as c, updateMicroservice as d, upsertTenantOption as f, GENERATED_LIVENESS_ROUTE as g, GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE as h, deleteTenantOption as i, subscribeToApplication as l, createC8yManifestFromNitro as m, createBasicAuthHeader as n, getBootstrapCredentials as o, createC8yManifest as p, createMicroservice as r,
|
|
427
|
+
export { GENERATED_READINESS_ROUTE as _, findMicroserviceByName as a, getTenantOptionsByCategory as c, updateMicroservice as d, upsertTenantOption as f, GENERATED_LIVENESS_ROUTE as g, GENERATED_INVALIDATE_TENANT_OPTIONS_ROUTE as h, deleteTenantOption as i, subscribeToApplication as l, createC8yManifestFromNitro as m, createBasicAuthHeader as n, getBootstrapCredentials as o, createC8yManifest as p, createMicroservice as r, getCurrentApplicationSettings as s, assignUserRole as t, unassignUserRole as u };
|
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-BHOzdhHp.mjs";
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
//#region src/cli/index.ts
|
|
4
4
|
runMain(defineCommand({
|
|
@@ -8,9 +8,9 @@ runMain(defineCommand({
|
|
|
8
8
|
description
|
|
9
9
|
},
|
|
10
10
|
subCommands: {
|
|
11
|
-
bootstrap: () => import("../bootstrap-
|
|
12
|
-
roles: () => import("../roles-
|
|
13
|
-
options: () => import("../options-
|
|
11
|
+
bootstrap: () => import("../bootstrap-C1Qly0J_.mjs").then((r) => r.default),
|
|
12
|
+
roles: () => import("../roles-B-DNuScZ.mjs").then((r) => r.default),
|
|
13
|
+
options: () => import("../options-CmLpZ3Qf.mjs").then((r) => r.default)
|
|
14
14
|
}
|
|
15
15
|
}));
|
|
16
16
|
//#endregion
|
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
const FAST_DOMAIN_SUCCESS_TTL = 6e4;
|
|
100
|
+
const FAST_DOMAIN_FAILURE_TTL = 1e4;
|
|
101
|
+
const FAST_DOMAIN_MAX_ENTRIES = 1e3;
|
|
102
|
+
const fastDomainCache = /* @__PURE__ */ new Map();
|
|
103
|
+
/**
|
|
104
|
+
* Best-effort variant of {@link getUserTenantDomain} for per-request hot paths:
|
|
105
|
+
* never throws (returns `undefined` for unauthenticated requests or failed
|
|
106
|
+
* lookups) and memoizes results in-process per auth material. Failures are
|
|
107
|
+
* cached briefly so requests with unresolvable credentials don't trigger a
|
|
108
|
+
* platform call each time.
|
|
109
|
+
* @param requestOrEvent - The H3Event or ServerRequest from the current request
|
|
110
|
+
* @param resolveDomain - Domain resolver, only overridden in tests
|
|
111
|
+
*/
|
|
112
|
+
async function tryGetUserTenantDomainFast(requestOrEvent, resolveDomain = getUserTenantDomain) {
|
|
113
|
+
if (!getCurrentUserTenantCacheKeyMaterial(requestOrEvent)) return;
|
|
114
|
+
const key = createCurrentUserTenantCacheKey(requestOrEvent);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const cached = fastDomainCache.get(key);
|
|
117
|
+
if (cached && cached.expiresAt > now) return cached.domain;
|
|
118
|
+
let domain;
|
|
119
|
+
try {
|
|
120
|
+
domain = await resolveDomain(requestOrEvent);
|
|
121
|
+
} catch {
|
|
122
|
+
domain = void 0;
|
|
123
|
+
}
|
|
124
|
+
if (!fastDomainCache.has(key) && fastDomainCache.size >= FAST_DOMAIN_MAX_ENTRIES) fastDomainCache.delete(fastDomainCache.keys().next().value);
|
|
125
|
+
fastDomainCache.set(key, {
|
|
126
|
+
domain,
|
|
127
|
+
expiresAt: now + (domain ? FAST_DOMAIN_SUCCESS_TTL : FAST_DOMAIN_FAILURE_TTL)
|
|
128
|
+
});
|
|
129
|
+
return domain;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Builds the public URL a request is reachable at through the platform proxy:
|
|
133
|
+
* `https://<tenant domain>/service/<contextPath><pathname><search>`.
|
|
134
|
+
* The proxy strips the `/service/<contextPath>` prefix before the request
|
|
135
|
+
* reaches the microservice, so it is re-added here.
|
|
136
|
+
* @param tenantDomain - Public domain of the tenant (e.g. `tenant.cumulocity.com`)
|
|
137
|
+
* @param contextPath - Context path of the microservice
|
|
138
|
+
* @param url - URL the request was received with (only pathname and search are used)
|
|
139
|
+
*/
|
|
140
|
+
function buildPublicRequestUrl(tenantDomain, contextPath, url) {
|
|
141
|
+
return new URL(`https://${tenantDomain}/service/${contextPath}${url.pathname}${url.search}`);
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/utils/credentials.ts
|
|
145
|
+
let prevCredentials = null;
|
|
146
|
+
function shouldEmitTenantCredentialsUpdated(prev, next) {
|
|
147
|
+
if (!prev) return true;
|
|
148
|
+
const prevTenantIds = Object.keys(prev);
|
|
149
|
+
const nextTenantIds = Object.keys(next);
|
|
150
|
+
if (prevTenantIds.length !== nextTenantIds.length) return true;
|
|
151
|
+
return new Set(nextTenantIds).symmetricDifference(new Set(prevTenantIds)).size > 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Fetches credentials for all tenants subscribed to this microservice.\
|
|
155
|
+
* Uses bootstrap credentials from runtime config to query the microservice subscriptions API.\
|
|
156
|
+
* Results are cached based on the configured TTL (default: 10 minutes).\
|
|
157
|
+
* @returns Object mapping tenant IDs to their respective credentials
|
|
158
|
+
* @config Cache TTL can be configured via:
|
|
159
|
+
* - `c8y.cache.credentialsTTL` in the Nitro config (value in seconds)
|
|
160
|
+
* - `NITRO_C8Y_CREDENTIALS_CACHE_TTL` environment variable
|
|
161
|
+
* @example
|
|
162
|
+
* // Get all subscribed tenant credentials:
|
|
163
|
+
* const credentials = await useSubscribedTenantCredentials()
|
|
164
|
+
* console.log(Object.keys(credentials)) // ['t12345', 't67890']
|
|
165
|
+
*
|
|
166
|
+
* // Access specific tenant:
|
|
167
|
+
* const tenant1Creds = credentials['t12345']
|
|
168
|
+
*
|
|
169
|
+
* // Invalidate cache:
|
|
170
|
+
* await useSubscribedTenantCredentials.invalidate()
|
|
171
|
+
*
|
|
172
|
+
* // Force refresh:
|
|
173
|
+
* const freshCreds = await useSubscribedTenantCredentials.refresh()
|
|
174
|
+
*/
|
|
175
|
+
const cachedSubscribedTenantCredentials = defineCachedFunction(async () => {
|
|
176
|
+
const newCredentials = (await Client.getMicroserviceSubscriptions({
|
|
177
|
+
tenant: process.env.C8Y_BOOTSTRAP_TENANT,
|
|
178
|
+
user: process.env.C8Y_BOOTSTRAP_USER,
|
|
179
|
+
password: process.env.C8Y_BOOTSTRAP_PASSWORD
|
|
180
|
+
}, process.env.C8Y_BASEURL)).reduce((acc, cred) => {
|
|
181
|
+
if (cred.tenant) acc[cred.tenant] = cred;
|
|
182
|
+
return acc;
|
|
183
|
+
}, {});
|
|
184
|
+
if (shouldEmitTenantCredentialsUpdated(prevCredentials, newCredentials)) useNitroHooks().callHook("c8y:tenantCredentialsUpdated", prevCredentials, newCredentials);
|
|
185
|
+
prevCredentials = newCredentials;
|
|
186
|
+
return newCredentials;
|
|
187
|
+
}, {
|
|
188
|
+
maxAge: useRuntimeConfig().c8yCredentialsCacheTTL ?? 600,
|
|
189
|
+
name: "_c8y_nitro_get_subscribed_tenant_credentials",
|
|
190
|
+
group: "c8y_nitro",
|
|
191
|
+
swr: false
|
|
192
|
+
});
|
|
193
|
+
const useSubscribedTenantCredentials = Object.assign(async () => {
|
|
194
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
195
|
+
prevCredentials = credentials;
|
|
196
|
+
return credentials;
|
|
197
|
+
}, {
|
|
198
|
+
invalidate: cachedSubscribedTenantCredentials.invalidate,
|
|
199
|
+
refresh: async () => {
|
|
200
|
+
await cachedSubscribedTenantCredentials.invalidate();
|
|
201
|
+
const credentials = await cachedSubscribedTenantCredentials();
|
|
202
|
+
prevCredentials = credentials;
|
|
203
|
+
return credentials;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
/**
|
|
207
|
+
* Fetches credentials for the tenant where this microservice is deployed.\
|
|
208
|
+
* Uses the C8Y_BOOTSTRAP_TENANT environment variable to identify the deployed tenant.\
|
|
209
|
+
* Returns credentials from the subscribed tenant credentials cache (cached based on configured TTL, default: 10 minutes).
|
|
210
|
+
* @returns Credentials for the deployed tenant
|
|
211
|
+
* @throws {EvlogError} If no credentials found for the deployed tenant
|
|
212
|
+
* @example
|
|
213
|
+
* // Get deployed tenant credentials:
|
|
214
|
+
* const creds = await useDeployedTenantCredentials()
|
|
215
|
+
* console.log(creds.tenant, creds.user)
|
|
216
|
+
*
|
|
217
|
+
* // Invalidate cache:
|
|
218
|
+
* await useDeployedTenantCredentials.invalidate()
|
|
219
|
+
*
|
|
220
|
+
* // Force refresh:
|
|
221
|
+
* const freshCreds = await useDeployedTenantCredentials.refresh()
|
|
222
|
+
* @note This function is not cached separately. It uses the cache of `useSubscribedTenantCredentials()`. Invalidating or refreshing one will refresh `useDeployedTenantCredentials()`s cache.
|
|
223
|
+
*/
|
|
224
|
+
const useDeployedTenantCredentials = Object.assign(async () => {
|
|
225
|
+
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
226
|
+
const allCredsPromise = await useSubscribedTenantCredentials();
|
|
227
|
+
if (!allCredsPromise[tenant]) throw createError({
|
|
228
|
+
message: `No credentials found for tenant deployed tenant '${tenant}'`,
|
|
229
|
+
status: 500
|
|
230
|
+
});
|
|
231
|
+
return allCredsPromise[tenant];
|
|
232
|
+
}, {
|
|
233
|
+
invalidate: useSubscribedTenantCredentials.invalidate,
|
|
234
|
+
refresh: async () => {
|
|
235
|
+
await useSubscribedTenantCredentials.refresh();
|
|
236
|
+
return await useDeployedTenantCredentials();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/utils/client.ts
|
|
241
|
+
/**
|
|
242
|
+
* Creates a Cumulocity client authenticated with the current user's credentials.\
|
|
243
|
+
* Extracts credentials from the Authorization header of the current request.
|
|
244
|
+
* @param requestOrEvent - The H3Event or ServerRequest from the current request
|
|
245
|
+
* @returns A configured Cumulocity Client instance
|
|
246
|
+
* @example
|
|
247
|
+
* // In a request handler:
|
|
248
|
+
* const client = useUserClient(event)
|
|
249
|
+
* const { data } = await client.inventory.list()
|
|
250
|
+
*/
|
|
251
|
+
function useUserClient(requestOrEvent) {
|
|
252
|
+
const request = "req" in requestOrEvent ? requestOrEvent.req : requestOrEvent;
|
|
253
|
+
if (request.context?.["c8y_user_client"]) return request.context["c8y_user_client"];
|
|
254
|
+
const client = new Client(new MicroserviceClientRequestAuth(convertRequestHeadersToC8yFormat(request)), process.env.C8Y_BASEURL);
|
|
255
|
+
request.context ??= {};
|
|
256
|
+
request.context["c8y_user_client"] = client;
|
|
257
|
+
return client;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Creates a Cumulocity client for the tenant where this microservice is deployed.\
|
|
261
|
+
* Uses the bootstrap tenant ID from runtime config to identify the deployed tenant.\
|
|
262
|
+
* @returns A configured Cumulocity Client instance for the deployed tenant
|
|
263
|
+
* @example
|
|
264
|
+
* // Get client for the tenant hosting this microservice:
|
|
265
|
+
* const client = await useDeployedTenantClient()
|
|
266
|
+
* const { data } = await client.application.list()
|
|
267
|
+
*/
|
|
268
|
+
async function useDeployedTenantClient() {
|
|
269
|
+
const creds = await useSubscribedTenantCredentials();
|
|
270
|
+
const tenant = process.env.C8Y_BOOTSTRAP_TENANT;
|
|
271
|
+
if (!creds[tenant]) throw createError({
|
|
272
|
+
message: `No subscribed tenant credentials found for tenant '${tenant}'`,
|
|
273
|
+
status: 500
|
|
274
|
+
});
|
|
275
|
+
const client = new Client(new BasicAuth(creds[tenant]), process.env.C8Y_BASEURL);
|
|
276
|
+
client.core.tenant = tenant;
|
|
277
|
+
return client;
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
export { buildPublicRequestUrl as n, tryGetUserTenantDomainFast as r, 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 };
|