@oondemand/oon-core-back 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@ O Core suporta `ecosystem.role` em `central.config.js`: `root` para a Central de
4
4
 
5
5
  ## Variáveis de ambiente
6
6
 
7
- - `CENTRAL_ATIVACAO_URL`: backend real da Central de Ativações; nunca aponte para o próprio Core.
7
+ - `CENTRAL_ATIVACAO_URL`: URL pública/frontend da Central de Ativações.
8
+ - `CENTRAL_ATIVACAO_API_URL`: URL canônica do backend da Central de Ativações. Padrão: `https://central-ativacao.central.oondemand.online/api/`.
8
9
  - `APP_CODE`: código do aplicativo no Ecossistema.
9
10
  - `APP_ENVIRONMENT`: `desenvolvimento`, `homologacao` ou `producao`.
10
11
  - `PUBLIC_APP_URL`: URL pública confirmada da Central.
@@ -12,6 +13,8 @@ O Core suporta `ecosystem.role` em `central.config.js`: `root` para a Central de
12
13
  - `AUTH_PROVIDER_TIMEOUT_MS`: timeout das chamadas à Central de Ativações.
13
14
  - `INSTANCE_HEARTBEAT_INTERVAL_MS`: intervalo planejado para sincronização/heartbeat.
14
15
 
16
+ Compatibilidade temporária: `CENTRAL_ATIVACAO_BACKEND_URL` e `MEUS_APPS_BACKEND_URL` continuam aceitas como aliases da URL da API. Nunca aponte a URL da API para o próprio Core.
17
+
15
18
  ## Fluxo
16
19
 
17
20
  `GET /ativacao/status` informa estado sem segredos. `POST /ativacao/validar-codigo` valida sem consumir. `POST /ativacao/concluir` revalida, chama `/ativar`, criptografa imediatamente o token da instância, executa hooks declarativos, chama `/concluir` e marca a instância como `ativa`. `POST /ativacao/tentar-novamente` retoma uma ativação já registrada sem exigir novo código.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -4,7 +4,7 @@ const ctx = require("../context");
4
4
  const DEFAULT_CENTRAL_ATIVACAO_URL =
5
5
  "https://central-ativacao.central.oondemand.online";
6
6
  const DEFAULT_CENTRAL_ATIVACAO_API_URL =
7
- "https://api.central-ativacao.central.oondemand.online";
7
+ "https://central-ativacao.central.oondemand.online/api/";
8
8
  const LOGIN_PATH = "/integracao/autenticacao-aplicativos/autenticar";
9
9
  const PERMISSION_PATH = "/integracao/autenticacao-aplicativos/verificar-permissao";
10
10
 
@@ -50,7 +50,7 @@ function assertNotSameHost(url, req) {
50
50
  process.env.AUTH_ALLOW_SAME_HOST !== "true"
51
51
  ) {
52
52
  throw new GenericError(
53
- "CENTRAL_ATIVACAO_URL aponta para o próprio Core. Configure o endereço da Central de Ativações.",
53
+ "CENTRAL_ATIVACAO_API_URL aponta para o próprio Core. Configure a API da Central de Ativações.",
54
54
  { statusCode: 500 },
55
55
  );
56
56
  }
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+
3
+ const { mongo } = require("mongoose");
4
+
5
+ const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
6
+ const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
7
+
8
+ function randomSecret(bytes = 32) {
9
+ return require("node:crypto").randomBytes(bytes).toString("base64url");
10
+ }
11
+
12
+ function dnsLabel(value, max = 63) {
13
+ return String(value || "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]+/g, "-")
16
+ .replace(/^-+|-+$/g, "")
17
+ .slice(0, max)
18
+ .replace(/-+$/, "");
19
+ }
20
+
21
+ function mongoIdentity(appCode, environment) {
22
+ return {
23
+ dbName: dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_"),
24
+ username: dnsLabel(`${appCode}-${environment}`, 48),
25
+ };
26
+ }
27
+
28
+ function mongoHost(uri) {
29
+ try {
30
+ return new URL(uri).hostname.toLowerCase();
31
+ } catch {
32
+ throw new Error("MONGO_ADMIN_URI inválida.");
33
+ }
34
+ }
35
+
36
+ function resolveMongoProvisioner({ adminUri, provisioner = "auto" }) {
37
+ const normalized = String(provisioner || "auto").trim().toLowerCase();
38
+ if (!["auto", "native", "digitalocean"].includes(normalized)) {
39
+ throw new Error(
40
+ `MONGO_PROVISIONER inválido: ${normalized}. Use auto, native ou digitalocean.`,
41
+ );
42
+ }
43
+ if (normalized !== "auto") return normalized;
44
+ return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX)
45
+ ? "digitalocean"
46
+ : "native";
47
+ }
48
+
49
+ function buildRuntimeMongoUri({
50
+ adminUri,
51
+ username,
52
+ password,
53
+ dbName,
54
+ authSource,
55
+ }) {
56
+ const uri = new URL(adminUri);
57
+ uri.username = username;
58
+ uri.password = password;
59
+ uri.pathname = `/${dbName}`;
60
+ uri.searchParams.set("authSource", authSource);
61
+ return uri.toString();
62
+ }
63
+
64
+ async function validateMongoUri(
65
+ mongoUri,
66
+ {
67
+ MongoClient = mongo.MongoClient,
68
+ timeoutMs = 15000,
69
+ writeProbe = true,
70
+ } = {},
71
+ ) {
72
+ const client = new MongoClient(mongoUri, {
73
+ serverSelectionTimeoutMS: timeoutMs,
74
+ connectTimeoutMS: timeoutMs,
75
+ maxPoolSize: 2,
76
+ });
77
+
78
+ await client.connect();
79
+ try {
80
+ const db = client.db();
81
+ await db.command({ ping: 1 });
82
+ if (writeProbe) {
83
+ const probe = db.collection("__oon_runtime_probe");
84
+ const id = "provisioning";
85
+ await probe.updateOne(
86
+ { _id: id },
87
+ { $set: { checkedAt: new Date() } },
88
+ { upsert: true },
89
+ );
90
+ await probe.deleteOne({ _id: id });
91
+ }
92
+ } finally {
93
+ await client.close();
94
+ }
95
+ return true;
96
+ }
97
+
98
+ async function waitForMongoUri(
99
+ mongoUri,
100
+ {
101
+ validate = validateMongoUri,
102
+ attempts = 12,
103
+ delayMs = 5000,
104
+ } = {},
105
+ ) {
106
+ let lastError;
107
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
108
+ try {
109
+ await validate(mongoUri);
110
+ return true;
111
+ } catch (error) {
112
+ lastError = error;
113
+ if (attempt < attempts) {
114
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
115
+ }
116
+ }
117
+ }
118
+ const reason = lastError?.codeName || lastError?.code || lastError?.name;
119
+ throw new Error(
120
+ `A credencial derivada do MongoDB não passou no teste de leitura e escrita${
121
+ reason ? ` (${reason})` : ""
122
+ }.`,
123
+ );
124
+ }
125
+
126
+ async function provisionNativeMongo({ adminUri, appCode, environment }) {
127
+ const { dbName, username } = mongoIdentity(appCode, environment);
128
+ const password = randomSecret(30);
129
+ const client = new mongo.MongoClient(adminUri, {
130
+ serverSelectionTimeoutMS: 15000,
131
+ });
132
+ await client.connect();
133
+ try {
134
+ const db = client.db(dbName);
135
+ try {
136
+ await db.command({
137
+ createUser: username,
138
+ pwd: password,
139
+ roles: [{ role: "readWrite", db: dbName }],
140
+ });
141
+ } catch (error) {
142
+ const alreadyExists =
143
+ error.codeName === "DuplicateKey" ||
144
+ error.code === 51003 ||
145
+ /already exists/i.test(error.message);
146
+ if (!alreadyExists) throw error;
147
+ await db.command({
148
+ updateUser: username,
149
+ pwd: password,
150
+ roles: [{ role: "readWrite", db: dbName }],
151
+ });
152
+ }
153
+ } finally {
154
+ await client.close();
155
+ }
156
+
157
+ return {
158
+ dbName,
159
+ username,
160
+ mongoUri: buildRuntimeMongoUri({
161
+ adminUri,
162
+ username,
163
+ password,
164
+ dbName,
165
+ authSource: dbName,
166
+ }),
167
+ provider: "native",
168
+ };
169
+ }
170
+
171
+ async function digitalOceanRequest(
172
+ {
173
+ token,
174
+ clusterId,
175
+ path,
176
+ method = "GET",
177
+ body,
178
+ expected = [200],
179
+ },
180
+ { fetchImpl = globalThis.fetch } = {},
181
+ ) {
182
+ if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
183
+ const controller = new AbortController();
184
+ const timeout = setTimeout(() => controller.abort(), 20000);
185
+ let response;
186
+ try {
187
+ response = await fetchImpl(
188
+ `${DIGITALOCEAN_API}/databases/${encodeURIComponent(clusterId)}${path}`,
189
+ {
190
+ method,
191
+ headers: {
192
+ Authorization: `Bearer ${token}`,
193
+ Accept: "application/json",
194
+ ...(body ? { "Content-Type": "application/json" } : {}),
195
+ },
196
+ body: body ? JSON.stringify(body) : undefined,
197
+ signal: controller.signal,
198
+ },
199
+ );
200
+ } catch (error) {
201
+ if (error.name === "AbortError") {
202
+ throw new Error("Timeout ao acessar a DigitalOcean API.");
203
+ }
204
+ throw new Error(`Falha ao acessar a DigitalOcean API: ${error.message}`);
205
+ } finally {
206
+ clearTimeout(timeout);
207
+ }
208
+
209
+ const payload = await response.json().catch(() => ({}));
210
+ if (!expected.includes(response.status)) {
211
+ const error = new Error(
212
+ `DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${payload.id || "erro"}).`,
213
+ );
214
+ error.status = response.status;
215
+ error.payload = payload;
216
+ throw error;
217
+ }
218
+ return { status: response.status, payload };
219
+ }
220
+
221
+ async function ensureDigitalOceanDatabase(options, deps) {
222
+ const path = `/dbs/${encodeURIComponent(options.dbName)}`;
223
+ try {
224
+ await digitalOceanRequest(
225
+ { ...options, path, expected: [200] },
226
+ deps,
227
+ );
228
+ return;
229
+ } catch (error) {
230
+ if (error.status !== 404) throw error;
231
+ }
232
+
233
+ await digitalOceanRequest(
234
+ {
235
+ ...options,
236
+ path: "/dbs",
237
+ method: "POST",
238
+ body: { name: options.dbName },
239
+ expected: [201, 202],
240
+ },
241
+ deps,
242
+ );
243
+ }
244
+
245
+ function digitalOceanUserSettings(dbName) {
246
+ return {
247
+ mongo_user_settings: {
248
+ databases: [dbName],
249
+ role: "readWrite",
250
+ },
251
+ };
252
+ }
253
+
254
+ async function provisionDigitalOceanUser(options, deps) {
255
+ const userPath = `/users/${encodeURIComponent(options.username)}`;
256
+ let exists = true;
257
+ try {
258
+ await digitalOceanRequest(
259
+ { ...options, path: userPath, expected: [200] },
260
+ deps,
261
+ );
262
+ } catch (error) {
263
+ if (error.status !== 404) throw error;
264
+ exists = false;
265
+ }
266
+
267
+ const settings = digitalOceanUserSettings(options.dbName);
268
+ let result;
269
+ if (!exists) {
270
+ result = await digitalOceanRequest(
271
+ {
272
+ ...options,
273
+ path: "/users",
274
+ method: "POST",
275
+ body: { name: options.username, settings },
276
+ expected: [201, 202],
277
+ },
278
+ deps,
279
+ );
280
+ } else {
281
+ await digitalOceanRequest(
282
+ {
283
+ ...options,
284
+ path: userPath,
285
+ method: "PUT",
286
+ body: { settings },
287
+ expected: [200, 202],
288
+ },
289
+ deps,
290
+ );
291
+ result = await digitalOceanRequest(
292
+ {
293
+ ...options,
294
+ path: `${userPath}/reset_auth`,
295
+ method: "POST",
296
+ body: {},
297
+ expected: [200, 201, 202],
298
+ },
299
+ deps,
300
+ );
301
+ }
302
+
303
+ const user = result.payload.user;
304
+ if (!user?.password) {
305
+ throw new Error(
306
+ "DigitalOcean API não retornou a senha do usuário. O token precisa do escopo database:view_credentials.",
307
+ );
308
+ }
309
+ return user;
310
+ }
311
+
312
+ async function provisionDigitalOceanMongo(
313
+ {
314
+ adminUri,
315
+ appCode,
316
+ environment,
317
+ digitalOceanToken,
318
+ digitalOceanClusterId,
319
+ },
320
+ deps = {},
321
+ ) {
322
+ if (!digitalOceanToken) {
323
+ throw new Error(
324
+ "DIGITALOCEAN_TOKEN é obrigatório para provisionar usuários no MongoDB gerenciado da DigitalOcean.",
325
+ );
326
+ }
327
+ if (!digitalOceanClusterId) {
328
+ throw new Error(
329
+ "DIGITALOCEAN_DATABASE_CLUSTER_ID é obrigatório para provisionar usuários no MongoDB gerenciado da DigitalOcean.",
330
+ );
331
+ }
332
+
333
+ const { dbName, username } = mongoIdentity(appCode, environment);
334
+ const apiOptions = {
335
+ token: digitalOceanToken,
336
+ clusterId: digitalOceanClusterId,
337
+ dbName,
338
+ username,
339
+ };
340
+ await ensureDigitalOceanDatabase(apiOptions, deps);
341
+ const user = await provisionDigitalOceanUser(apiOptions, deps);
342
+ const mongoUri = buildRuntimeMongoUri({
343
+ adminUri,
344
+ username: user.name || username,
345
+ password: user.password,
346
+ dbName,
347
+ authSource: "admin",
348
+ });
349
+
350
+ await waitForMongoUri(mongoUri, {
351
+ validate: deps.validateMongoUri || validateMongoUri,
352
+ attempts: deps.validationAttempts,
353
+ delayMs: deps.validationDelayMs,
354
+ });
355
+
356
+ return {
357
+ dbName,
358
+ username: user.name || username,
359
+ mongoUri,
360
+ provider: "digitalocean",
361
+ };
362
+ }
363
+
364
+ async function provisionMongo(options, deps = {}) {
365
+ const provider = resolveMongoProvisioner(options);
366
+ if (provider === "digitalocean") {
367
+ return provisionDigitalOceanMongo(options, deps);
368
+ }
369
+ const result = await provisionNativeMongo(options);
370
+ await waitForMongoUri(result.mongoUri, {
371
+ validate: deps.validateMongoUri || validateMongoUri,
372
+ attempts: deps.validationAttempts,
373
+ delayMs: deps.validationDelayMs,
374
+ });
375
+ return result;
376
+ }
377
+
378
+ module.exports = {
379
+ DIGITALOCEAN_API,
380
+ DIGITALOCEAN_HOST_SUFFIX,
381
+ mongoIdentity,
382
+ mongoHost,
383
+ resolveMongoProvisioner,
384
+ buildRuntimeMongoUri,
385
+ validateMongoUri,
386
+ waitForMongoUri,
387
+ provisionNativeMongo,
388
+ digitalOceanRequest,
389
+ ensureDigitalOceanDatabase,
390
+ digitalOceanUserSettings,
391
+ provisionDigitalOceanUser,
392
+ provisionDigitalOceanMongo,
393
+ provisionMongo,
394
+ };
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("node:crypto");
4
- const { mongo } = require("mongoose");
5
4
  const { spawnSync } = require("node:child_process");
6
5
  const { kubectl } = require("./command");
7
6
  const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
7
+ const { provisionMongo, validateMongoUri } = require("./mongoProvisioning");
8
8
 
9
9
  const RESOURCE_PROFILES = {
10
10
  nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
@@ -28,28 +28,6 @@ function readSecret(namespace, name, key) {
28
28
  } catch { return null; }
29
29
  }
30
30
 
31
- async function provisionMongo({ adminUri, appCode, environment }) {
32
- const dbName = dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_");
33
- const username = dnsLabel(`${appCode}-${environment}`, 48);
34
- const password = randomSecret(30);
35
- const client = new mongo.MongoClient(adminUri, { serverSelectionTimeoutMS: 15000 });
36
- await client.connect();
37
- try {
38
- const db = client.db(dbName);
39
- try { await db.command({ createUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] }); }
40
- catch (error) {
41
- if (error.codeName !== "DuplicateKey" && error.code !== 51003 && !/already exists/i.test(error.message)) throw error;
42
- await db.command({ updateUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] });
43
- }
44
- } finally { await client.close(); }
45
- const uri = new URL(adminUri);
46
- uri.username = username;
47
- uri.password = password;
48
- uri.pathname = `/${dbName}`;
49
- uri.searchParams.set("authSource", dbName);
50
- return { dbName, mongoUri: uri.toString() };
51
- }
52
-
53
31
  function baselineManifest({ context }) {
54
32
  return `apiVersion: v1
55
33
  kind: Namespace
@@ -189,18 +167,71 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
189
167
  async function provisionRuntime({ context, extraSecrets = {} }) {
190
168
  const infra = validateInfraFromCluster().config;
191
169
  applyYaml(baselineManifest({ context, infra }));
170
+
171
+ const platformNamespace = infra.PLATFORM_NAMESPACE || "oon-system";
172
+ const adminSecretName = infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin";
173
+ const adminUri = readSecret(
174
+ platformNamespace,
175
+ adminSecretName,
176
+ "MONGO_ADMIN_URI",
177
+ );
178
+ if (!adminUri) {
179
+ throw new Error("MongoDB administrativo não configurado na plataforma.");
180
+ }
181
+
182
+ const mongoProvisioner =
183
+ readSecret(platformNamespace, adminSecretName, "MONGO_PROVISIONER") ||
184
+ "auto";
185
+ const digitalOceanToken = readSecret(
186
+ platformNamespace,
187
+ adminSecretName,
188
+ "DIGITALOCEAN_TOKEN",
189
+ );
190
+ const digitalOceanClusterId = readSecret(
191
+ platformNamespace,
192
+ adminSecretName,
193
+ "DIGITALOCEAN_DATABASE_CLUSTER_ID",
194
+ );
195
+
192
196
  let mongoUri = readSecret(context.namespace, "oon-runtime", "MONGO_URI");
193
197
  let databaseName = "";
198
+ let credentialRotated = false;
199
+
200
+ if (mongoUri) {
201
+ try {
202
+ await validateMongoUri(mongoUri);
203
+ } catch (error) {
204
+ console.warn(
205
+ "A credencial MongoDB persistida não passou na validação; " +
206
+ "ela será reprovisionada.",
207
+ );
208
+ mongoUri = null;
209
+ credentialRotated = true;
210
+ }
211
+ }
212
+
194
213
  if (!mongoUri) {
195
- const adminUri = readSecret(infra.PLATFORM_NAMESPACE || "oon-system", infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin", "MONGO_ADMIN_URI");
196
- if (!adminUri) throw new Error("MongoDB administrativo não configurado na plataforma.");
197
- const provisioned = await provisionMongo({ adminUri, appCode: context.appCode, environment: context.environment });
214
+ const provisioned = await provisionMongo({
215
+ adminUri,
216
+ appCode: context.appCode,
217
+ environment: context.environment,
218
+ provisioner: mongoProvisioner,
219
+ digitalOceanToken,
220
+ digitalOceanClusterId,
221
+ });
198
222
  mongoUri = provisioned.mongoUri;
199
223
  databaseName = provisioned.dbName;
224
+ credentialRotated = true;
200
225
  }
201
- const encryptionKey = readSecret(context.namespace, "oon-runtime", "INSTANCE_CREDENTIAL_ENCRYPTION_KEY") || randomSecret(48);
226
+
227
+ const encryptionKey =
228
+ readSecret(
229
+ context.namespace,
230
+ "oon-runtime",
231
+ "INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
232
+ ) || randomSecret(48);
202
233
  applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
203
- return { infra, databaseName };
234
+ return { infra, databaseName, credentialRotated };
204
235
  }
205
236
 
206
237
  function deployRuntime({ context, infra, imageDigest, resourceProfile = "small" }) {
@@ -249,4 +280,4 @@ async function activateMember(context) {
249
280
  return data;
250
281
  }
251
282
 
252
- module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, provisionMongo, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
283
+ module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };