@oondemand/oon-core-back 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,477 @@
1
+ const crypto = require("node:crypto");
2
+ const mongoose = require("mongoose");
3
+ const axios = require("axios");
4
+ const ctx = require("../context");
5
+ const GenericError = require("../errors/GenericError");
6
+ const Instancia = require("../models/InstanciaEcossistema");
7
+ const { getBaseUrl, getAppCode, getTimeout } = require("./activationProvider");
8
+
9
+ const PATHS = {
10
+ validar: "/integracao/ativacao-instancias/validar-codigo",
11
+ ativar: "/integracao/ativacao-instancias/ativar",
12
+ concluir: "/integracao/ativacao-instancias/concluir",
13
+ contexto: "/integracao/ativacao-instancias/contexto",
14
+ heartbeat: "/integracao/ativacao-instancias/heartbeat",
15
+ };
16
+
17
+ const globalDefaults = {
18
+ idioma: "pt-BR",
19
+ timezone: "America/Sao_Paulo",
20
+ moeda: "BRL",
21
+ pais: "BR",
22
+ formatoData: "DD/MM/YYYY",
23
+ };
24
+
25
+ const globalFields = [
26
+ {
27
+ name: "nomeInstancia",
28
+ label: "Nome da instância",
29
+ type: "text",
30
+ required: true,
31
+ },
32
+ {
33
+ name: "ambiente",
34
+ label: "Ambiente",
35
+ type: "select",
36
+ required: true,
37
+ readOnly: true,
38
+ options: ["desenvolvimento", "homologacao", "producao"],
39
+ },
40
+ {
41
+ name: "urlPublica",
42
+ label: "URL pública",
43
+ type: "url",
44
+ required: true,
45
+ },
46
+ {
47
+ name: "idioma",
48
+ label: "Idioma",
49
+ type: "text",
50
+ required: true,
51
+ defaultValue: "pt-BR",
52
+ },
53
+ {
54
+ name: "timezone",
55
+ label: "Timezone",
56
+ type: "text",
57
+ required: true,
58
+ defaultValue: "America/Sao_Paulo",
59
+ },
60
+ {
61
+ name: "moeda",
62
+ label: "Moeda",
63
+ type: "text",
64
+ required: true,
65
+ defaultValue: "BRL",
66
+ },
67
+ {
68
+ name: "pais",
69
+ label: "País",
70
+ type: "text",
71
+ required: true,
72
+ defaultValue: "BR",
73
+ },
74
+ {
75
+ name: "formatoData",
76
+ label: "Formato de data",
77
+ type: "text",
78
+ required: true,
79
+ defaultValue: "DD/MM/YYYY",
80
+ },
81
+ {
82
+ name: "emailAdministrativo",
83
+ label: "E-mail administrativo",
84
+ type: "email",
85
+ required: true,
86
+ },
87
+ ];
88
+
89
+ const rootFields = [
90
+ {
91
+ name: "email",
92
+ label: "E-mail do administrador",
93
+ type: "email",
94
+ required: true,
95
+ autoComplete: "email",
96
+ },
97
+ {
98
+ name: "senha",
99
+ label: "Senha",
100
+ type: "password",
101
+ required: true,
102
+ minLength: 8,
103
+ sensitive: true,
104
+ autoComplete: "new-password",
105
+ },
106
+ ];
107
+
108
+ function role() {
109
+ return ctx.config.ecosystem?.role === "root" ? "root" : "member";
110
+ }
111
+
112
+ function ambiente() {
113
+ const value =
114
+ process.env.APP_ENVIRONMENT || process.env.NODE_ENV || "desenvolvimento";
115
+ return ["desenvolvimento", "homologacao", "producao"].includes(value)
116
+ ? value
117
+ : "desenvolvimento";
118
+ }
119
+
120
+ function publicUrl(req) {
121
+ return (
122
+ process.env.PUBLIC_APP_URL ||
123
+ `${req?.protocol || "http"}://${req?.headers?.host || "localhost"}`
124
+ );
125
+ }
126
+
127
+ function version() {
128
+ return (
129
+ ctx.config.serviceVersion || process.env.npm_package_version || "0.0.0"
130
+ );
131
+ }
132
+
133
+ function fingerprint() {
134
+ return crypto
135
+ .createHash("sha256")
136
+ .update(
137
+ `${getAppCode()}|${publicUrl()}|${process.env.HOSTNAME || "local"}`,
138
+ )
139
+ .digest("hex");
140
+ }
141
+
142
+ function event(name, data = {}) {
143
+ console.log(
144
+ JSON.stringify({ event: name, ...data, at: new Date().toISOString() }),
145
+ );
146
+ }
147
+
148
+ function sanitizeFields(fields = []) {
149
+ return fields.map(
150
+ ({
151
+ name,
152
+ label,
153
+ type,
154
+ required,
155
+ defaultValue,
156
+ placeholder,
157
+ helpText,
158
+ section,
159
+ options,
160
+ min,
161
+ max,
162
+ minLength,
163
+ maxLength,
164
+ pattern,
165
+ sensitive,
166
+ visibleWhen,
167
+ readOnly,
168
+ autoComplete,
169
+ }) => ({
170
+ name,
171
+ label,
172
+ type,
173
+ required: Boolean(required),
174
+ defaultValue: ["password", "secret"].includes(type)
175
+ ? undefined
176
+ : defaultValue,
177
+ placeholder,
178
+ helpText,
179
+ section,
180
+ options,
181
+ min,
182
+ max,
183
+ minLength,
184
+ maxLength,
185
+ pattern,
186
+ sensitive:
187
+ Boolean(sensitive) || ["password", "secret"].includes(type),
188
+ visibleWhen,
189
+ readOnly,
190
+ autoComplete,
191
+ }),
192
+ );
193
+ }
194
+
195
+ function activationConfig() {
196
+ return {
197
+ enabled: ctx.config.activation?.enabled !== false,
198
+ configurationVersion:
199
+ ctx.config.activation?.configurationVersion || 1,
200
+ fields: ctx.config.activation?.fields || [],
201
+ hooks: ctx.config.activation || {},
202
+ rootBootstrap: ctx.config.activation?.rootBootstrap,
203
+ };
204
+ }
205
+
206
+ async function domainHasActiveRootApp() {
207
+ const hasActiveRootApp = ctx.config.activation?.hasActiveRootApp;
208
+ if (typeof hasActiveRootApp !== "function") return null;
209
+ return Boolean(await hasActiveRootApp());
210
+ }
211
+
212
+ function clearInstanceIdentity(instance) {
213
+ instance.instanceId = undefined;
214
+ instance.instanceCode = undefined;
215
+ instance.appId = undefined;
216
+ instance.tenantId = undefined;
217
+ instance.licencaId = undefined;
218
+ instance.credentialEncrypted = undefined;
219
+ instance.credentialIv = undefined;
220
+ instance.credentialAuthTag = undefined;
221
+ }
222
+
223
+ async function ensureInstance() {
224
+ if (mongoose.connection.readyState !== 1) {
225
+ return {
226
+ ecosystemRole: role(),
227
+ status: "ativa",
228
+ appCode: getAppCode(),
229
+ ambiente: ambiente(),
230
+ save: async () => {},
231
+ };
232
+ }
233
+
234
+ let instance = await Instancia.findOne().select(
235
+ "+credentialEncrypted +credentialIv +credentialAuthTag",
236
+ );
237
+
238
+ if (!instance) {
239
+ instance = await Instancia.create({
240
+ ecosystemRole: role(),
241
+ status: "nao_ativada",
242
+ appCode: getAppCode(),
243
+ ambiente: ambiente(),
244
+ ecosystemUrl: process.env.CENTRAL_ATIVACAO_URL,
245
+ publicUrl: process.env.PUBLIC_APP_URL,
246
+ coreVersion: version(),
247
+ appVersion: version(),
248
+ });
249
+ }
250
+
251
+ if (instance.ecosystemRole !== role()) {
252
+ instance.ecosystemRole = role();
253
+ instance.status = "nao_ativada";
254
+ clearInstanceIdentity(instance);
255
+ await instance.save();
256
+ }
257
+
258
+ // Versões anteriores do Core marcavam a aplicação root como ativa sem criar
259
+ // o App root e o grafo mínimo do ecossistema. Quando o domínio disponibiliza
260
+ // o verificador, o App cadastrado passa a ser a fonte de verdade.
261
+ if (instance.ecosystemRole === "root" && instance.status === "ativa") {
262
+ const domainActive = await domainHasActiveRootApp();
263
+ if (domainActive === false) {
264
+ instance.status = "nao_ativada";
265
+ clearInstanceIdentity(instance);
266
+ await instance.save();
267
+ }
268
+ }
269
+
270
+ return instance;
271
+ }
272
+
273
+ async function hasActiveRootInstance() {
274
+ if (mongoose.connection.readyState !== 1) return false;
275
+
276
+ const domainActive = await domainHasActiveRootApp();
277
+ if (domainActive !== null) return domainActive;
278
+
279
+ return Boolean(
280
+ await Instancia.exists({ ecosystemRole: "root", status: "ativa" }),
281
+ );
282
+ }
283
+
284
+ async function claimRootBootstrap() {
285
+ if (role() !== "root") {
286
+ throw new GenericError(
287
+ "Somente uma aplicação root pode executar o bootstrap do ecossistema.",
288
+ { statusCode: 400 },
289
+ );
290
+ }
291
+
292
+ await ensureInstance();
293
+
294
+ if (await hasActiveRootInstance()) {
295
+ throw new GenericError("Já existe um aplicativo root ativo.", {
296
+ statusCode: 409,
297
+ });
298
+ }
299
+
300
+ const staleBefore = new Date(Date.now() - 5 * 60 * 1000);
301
+ const instance = await Instancia.findOneAndUpdate(
302
+ {
303
+ singletonKey: "default",
304
+ ecosystemRole: "root",
305
+ $or: [
306
+ { status: { $in: ["nao_ativada", "erro"] } },
307
+ { status: "configurando", updatedAt: { $lt: staleBefore } },
308
+ ],
309
+ },
310
+ {
311
+ $set: {
312
+ status: "configurando",
313
+ ultimoErro: null,
314
+ },
315
+ },
316
+ { new: true },
317
+ ).select("+credentialEncrypted +credentialIv +credentialAuthTag");
318
+
319
+ if (!instance) {
320
+ throw new GenericError(
321
+ "A ativação root já está em andamento ou não pode ser iniciada.",
322
+ { statusCode: 409 },
323
+ );
324
+ }
325
+
326
+ return instance;
327
+ }
328
+
329
+ function validateRootCredentials(email, password) {
330
+ const normalizedEmail = String(email || "").trim().toLowerCase();
331
+ const normalizedPassword = String(password || "");
332
+
333
+ if (!/^\S+@\S+\.\S+$/.test(normalizedEmail)) {
334
+ throw new GenericError("Informe um e-mail válido.", { statusCode: 400 });
335
+ }
336
+
337
+ if (normalizedPassword.length < 8) {
338
+ throw new GenericError("A senha deve possuir pelo menos 8 caracteres.", {
339
+ statusCode: 400,
340
+ });
341
+ }
342
+
343
+ return { email: normalizedEmail, password: normalizedPassword };
344
+ }
345
+
346
+ function key() {
347
+ const raw = process.env.INSTANCE_CREDENTIAL_ENCRYPTION_KEY;
348
+ if (!raw && ambiente() === "producao") {
349
+ throw new GenericError(
350
+ "INSTANCE_CREDENTIAL_ENCRYPTION_KEY é obrigatória em produção.",
351
+ { statusCode: 500 },
352
+ );
353
+ }
354
+ const source = raw || "oon-dev-instance-credential-key-change-me";
355
+ return crypto.createHash("sha256").update(source).digest();
356
+ }
357
+
358
+ function encrypt(token) {
359
+ const iv = crypto.randomBytes(12);
360
+ const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv);
361
+ const encrypted = Buffer.concat([
362
+ cipher.update(token, "utf8"),
363
+ cipher.final(),
364
+ ]);
365
+ return {
366
+ credentialEncrypted: encrypted.toString("base64"),
367
+ credentialIv: iv.toString("base64"),
368
+ credentialAuthTag: cipher.getAuthTag().toString("base64"),
369
+ };
370
+ }
371
+
372
+ function decrypt(instance) {
373
+ if (!instance.credentialEncrypted) {
374
+ throw new GenericError("Credencial da instância indisponível.", {
375
+ statusCode: 423,
376
+ });
377
+ }
378
+ const decipher = crypto.createDecipheriv(
379
+ "aes-256-gcm",
380
+ key(),
381
+ Buffer.from(instance.credentialIv, "base64"),
382
+ );
383
+ decipher.setAuthTag(Buffer.from(instance.credentialAuthTag, "base64"));
384
+ return Buffer.concat([
385
+ decipher.update(Buffer.from(instance.credentialEncrypted, "base64")),
386
+ decipher.final(),
387
+ ]).toString("utf8");
388
+ }
389
+
390
+ function instanceHeaders(instance) {
391
+ return {
392
+ "x-oon-instance-id": instance.instanceId,
393
+ "x-oon-instance-token": decrypt(instance),
394
+ "x-app-code": getAppCode(),
395
+ };
396
+ }
397
+
398
+ function validateUrl(value) {
399
+ const url = new URL(value);
400
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error();
401
+ return url.toString().replace(/\/$/, "");
402
+ }
403
+
404
+ function validateParams(globalParameters = {}, appParameters = {}) {
405
+ for (const field of globalFields) {
406
+ if (
407
+ field.required &&
408
+ !globalParameters[field.name] &&
409
+ !["ambiente"].includes(field.name)
410
+ ) {
411
+ throw new GenericError(`Campo obrigatório: ${field.label}.`, {
412
+ statusCode: 400,
413
+ });
414
+ }
415
+ }
416
+
417
+ validateUrl(
418
+ globalParameters.urlPublica ||
419
+ process.env.PUBLIC_APP_URL ||
420
+ "http://localhost",
421
+ );
422
+
423
+ for (const field of activationConfig().fields) {
424
+ if (field.required && !appParameters[field.name]) {
425
+ throw new GenericError(
426
+ `Campo obrigatório: ${field.label || field.name}.`,
427
+ { statusCode: 400 },
428
+ );
429
+ }
430
+ }
431
+ }
432
+
433
+ async function providerPost(req, path, payload, headers = {}) {
434
+ return axios.post(`${getBaseUrl(req)}${path}`, payload, {
435
+ timeout: getTimeout(),
436
+ maxRedirects: 0,
437
+ headers: { "Content-Type": "application/json", ...headers },
438
+ });
439
+ }
440
+
441
+ function providerPayload(req, codigoAtivacao, extra = {}) {
442
+ return {
443
+ codigoAtivacao,
444
+ appCode: getAppCode(),
445
+ ambiente: ambiente(),
446
+ urlPublica: publicUrl(req),
447
+ coreVersion: version(),
448
+ appVersion: version(),
449
+ fingerprint: fingerprint(),
450
+ ...extra,
451
+ };
452
+ }
453
+
454
+ module.exports = {
455
+ PATHS,
456
+ globalDefaults,
457
+ globalFields,
458
+ rootFields,
459
+ role,
460
+ ambiente,
461
+ publicUrl,
462
+ version,
463
+ fingerprint,
464
+ sanitizeFields,
465
+ activationConfig,
466
+ ensureInstance,
467
+ hasActiveRootInstance,
468
+ claimRootBootstrap,
469
+ validateRootCredentials,
470
+ encrypt,
471
+ decrypt,
472
+ instanceHeaders,
473
+ validateParams,
474
+ providerPost,
475
+ providerPayload,
476
+ event,
477
+ };
@@ -33,7 +33,10 @@ const DEFAULTS = {
33
33
  JWT_SECRET: "",
34
34
  OMIE_APP_KEY: "",
35
35
  OMIE_APP_SECRET: "",
36
+ CENTRAL_ATIVACAO_URL: "",
36
37
  MEUS_APPS_BACKEND_URL: "",
38
+ APP_CODE: "",
39
+ AUTH_PROVIDER_TIMEOUT_MS: "10000",
37
40
  };
38
41
 
39
42
  const K8S_TEMPLATES = [
@@ -13,4 +13,7 @@ data:
13
13
  DB_AUTH_SOURCE: "${DB_AUTH_SOURCE}"
14
14
  DB_REPLICA_SET: "${DB_REPLICA_SET}"
15
15
  DB_TSL: "${DB_TSL}"
16
+ CENTRAL_ATIVACAO_URL: "${CENTRAL_ATIVACAO_URL}"
16
17
  MEUS_APPS_BACKEND_URL: "${MEUS_APPS_BACKEND_URL}"
18
+ APP_CODE: "${APP_CODE}"
19
+ AUTH_PROVIDER_TIMEOUT_MS: "${AUTH_PROVIDER_TIMEOUT_MS}"