@oondemand/oon-core-back 0.7.17 → 0.7.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.7.17",
3
+ "version": "0.7.20",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -103,6 +103,12 @@ router.get("/role-catalog", (_req, res) => {
103
103
  res.json(publicRoleCatalog());
104
104
  });
105
105
 
106
+ // F5.2: métricas de uso declaradas no central.app.json.
107
+ router.get("/usage-metrics", (_req, res) => {
108
+ res.set("Cache-Control", "public, max-age=300");
109
+ res.json(require("../manifest/usageMetrics").publicUsageMetrics(require("../core/context").config));
110
+ });
111
+
106
112
  router.get("/rbac", (_req, res) => {
107
113
  res.json(publicPolicy());
108
114
  });
@@ -25,29 +25,12 @@ const {
25
25
  event,
26
26
  } = require("../core/utils/instanceActivation");
27
27
  const { getAppCode, getBaseUrl } = require("../core/utils/activationProvider");
28
+ const { createActivationRateLimit } = require("./activationRateLimit");
28
29
 
29
30
  const router = express.Router();
30
- const attempts = new Map();
31
31
  const DEV_LOCAL_PATH = "/integracao/dev-local/ativar-central";
32
32
 
33
- function rateLimit(req, res, next) {
34
- const key = req.ip || "local";
35
- const now = Date.now();
36
- const list = (attempts.get(key) || []).filter((time) => now - time < 60000);
37
-
38
- if (list.length >= 30) {
39
- return res.status(429).json({
40
- error: {
41
- code: "RATE_LIMIT",
42
- message: "Muitas tentativas de ativação.",
43
- },
44
- });
45
- }
46
-
47
- list.push(now);
48
- attempts.set(key, list);
49
- next();
50
- }
33
+ const rateLimit = createActivationRateLimit();
51
34
 
52
35
  function assertMember() {
53
36
  if (role() === "root") {
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+
3
+ const mongoose = require("../core/database/mongoose");
4
+
5
+ /**
6
+ * Limite de tentativas nas rotas de ativação: `limit` requisições por IP a
7
+ * cada janela fixa de `windowMs`.
8
+ *
9
+ * O contador fica no MongoDB para valer para o App inteiro: com o contador em
10
+ * memória, cada réplica tinha o seu e o limite real virava `limit × réplicas`.
11
+ * Sem conexão com o banco (boot, queda), usa o contador em memória do processo
12
+ * para não bloquear a ativação.
13
+ */
14
+ const COLLECTION = "oon_activation_attempts";
15
+
16
+ function createActivationRateLimit({
17
+ limit = 30,
18
+ windowMs = 60000,
19
+ now = () => Date.now(),
20
+ connection = () => mongoose.connection,
21
+ } = {}) {
22
+ const memory = new Map();
23
+ let indexReady = null;
24
+
25
+ function memoryCount(key, windowStart) {
26
+ const current = memory.get(key);
27
+ const next = current && current.windowStart === windowStart ? current.count + 1 : 1;
28
+ memory.set(key, { windowStart, count: next });
29
+ if (memory.size > 10000) {
30
+ for (const [entryKey, entry] of memory) if (entry.windowStart !== windowStart) memory.delete(entryKey);
31
+ }
32
+ return next;
33
+ }
34
+
35
+ async function sharedCount(db, key, windowStart) {
36
+ const collection = db.collection(COLLECTION);
37
+ if (!indexReady) {
38
+ indexReady = collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch((error) => {
39
+ indexReady = null;
40
+ throw error;
41
+ });
42
+ }
43
+ await indexReady;
44
+ const result = await collection.findOneAndUpdate(
45
+ { _id: `${key}:${windowStart}` },
46
+ { $inc: { count: 1 }, $setOnInsert: { expiresAt: new Date(windowStart + windowMs * 2) } },
47
+ { upsert: true, returnDocument: "after", maxTimeMS: 2000 },
48
+ );
49
+ const doc = result && Object.hasOwn(result, "value") ? result.value : result;
50
+ return Number(doc?.count || 1);
51
+ }
52
+
53
+ async function count(key) {
54
+ const windowStart = Math.floor(now() / windowMs) * windowMs;
55
+ const db = connection();
56
+ if (db?.readyState === 1) {
57
+ try {
58
+ return await sharedCount(db, key, windowStart);
59
+ } catch (error) {
60
+ console.warn(`[OonCore] Limite de ativação usando contador local: ${error?.message || error}`);
61
+ }
62
+ }
63
+ return memoryCount(key, windowStart);
64
+ }
65
+
66
+ return async function activationRateLimit(req, res, next) {
67
+ try {
68
+ const attempts = await count(req.ip || "local");
69
+ if (attempts > limit) {
70
+ return res.status(429).json({
71
+ error: {
72
+ code: "RATE_LIMIT",
73
+ message: "Muitas tentativas de ativação.",
74
+ },
75
+ });
76
+ }
77
+ return next();
78
+ } catch (error) {
79
+ return next(error);
80
+ }
81
+ };
82
+ }
83
+
84
+ module.exports = { createActivationRateLimit, COLLECTION };
@@ -1,7 +1,7 @@
1
1
  const mongoose = require("../core/database/mongoose");
2
2
  const registry = require("../core/registry");
3
3
  const ctx = require("../core/context");
4
- const { DEFAULTS, poolOptions } = require("../core/database/policy");
4
+ const { poolOptions, resolvePolicy } = require("../core/database/policy");
5
5
  const { attachPoolMetrics } = require("../core/database/metrics");
6
6
 
7
7
  function buildMongoUri() {
@@ -23,7 +23,7 @@ function buildMongoUri() {
23
23
  }
24
24
 
25
25
  function connectionOptions() {
26
- const pool = poolOptions();
26
+ const pool = poolOptions(resolvePolicy().policy);
27
27
  if (process.env.MONGO_URI) return pool;
28
28
 
29
29
  const hasUser = Boolean(process.env.DB_USER);
@@ -49,24 +49,55 @@ function databaseLabel(uri) {
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * Modo da sincronização de índices no boot (`OON_SYNC_INDEXES`):
54
+ * - `create` (padrão; também `true`): só cria os índices declarados que faltam
55
+ * e informa os obsoletos sem removê-los. Seguro com várias réplicas subindo
56
+ * juntas e durante o rolling update, quando a versão anterior ainda está no
57
+ * ar e pode depender de um índice que a nova versão não declara mais.
58
+ * - `drop`: comportamento anterior (`syncIndexes`), que também remove os
59
+ * obsoletos. Usar apenas de forma explícita, com uma única réplica no ar.
60
+ * - `false`: não sincroniza.
61
+ */
62
+ function indexSyncMode() {
63
+ const value = String(process.env.OON_SYNC_INDEXES ?? "create").trim().toLowerCase();
64
+ if (value === "false") return "off";
65
+ if (value === "drop") return "drop";
66
+ return "create";
67
+ }
68
+
52
69
  function shouldSyncDeclaredIndexes() {
53
- return String(process.env.OON_SYNC_INDEXES ?? "true").toLowerCase() !== "false";
70
+ return indexSyncMode() !== "off";
54
71
  }
55
72
 
56
73
  async function syncDeclaredIndexes(entries = registry.listModels()) {
57
- if (!shouldSyncDeclaredIndexes()) return [];
74
+ const mode = indexSyncMode();
75
+ if (mode === "off") return [];
58
76
 
59
77
  const results = [];
60
78
  for (const entry of entries) {
61
79
  const model = entry?.mongooseModel;
62
- if (!model || typeof model.syncIndexes !== "function") continue;
63
-
64
- const dropped = await model.syncIndexes();
65
- const normalized = Array.isArray(dropped) ? dropped : [];
66
- results.push({ model: entry.name, dropped: normalized });
80
+ if (!model) continue;
81
+
82
+ if (mode === "drop") {
83
+ if (typeof model.syncIndexes !== "function") continue;
84
+ const dropped = await model.syncIndexes();
85
+ const normalized = Array.isArray(dropped) ? dropped : [];
86
+ results.push({ model: entry.name, dropped: normalized, obsolete: [] });
87
+ if (normalized.length) {
88
+ console.log(`[OonCore] Índices obsoletos removidos de ${entry.name}: ${normalized.join(", ")}`);
89
+ }
90
+ continue;
91
+ }
67
92
 
68
- if (normalized.length) {
69
- console.log(`[OonCore] Índices obsoletos removidos de ${entry.name}: ${normalized.join(", ")}`);
93
+ if (typeof model.createIndexes !== "function") continue;
94
+ // createIndex é idempotente no MongoDB: réplicas simultâneas não conflitam.
95
+ await model.createIndexes();
96
+ const diff = typeof model.diffIndexes === "function" ? await model.diffIndexes() : null;
97
+ const obsolete = Array.isArray(diff?.toDrop) ? diff.toDrop : [];
98
+ results.push({ model: entry.name, dropped: [], obsolete });
99
+ if (obsolete.length) {
100
+ console.log(`[OonCore] Índices não declarados mantidos em ${entry.name}: ${obsolete.join(", ")} (remova com OON_SYNC_INDEXES=drop em uma janela com uma única réplica).`);
70
101
  }
71
102
  }
72
103
 
@@ -89,7 +120,8 @@ async function connectDB() {
89
120
  metrics?.dispose();
90
121
  throw error;
91
122
  }
92
- console.log("[OonCore] database policy", JSON.stringify({ ...DEFAULTS, uncoveredModels: Object.values(mongoose.models).filter(model => !model.schema[Symbol.for("oon.database.policy.schema.v1")]).length }));
123
+ const { mode, policy } = resolvePolicy();
124
+ console.log("[OonCore] database policy", JSON.stringify({ performanceMode: mode, ...policy, uncoveredModels: Object.values(mongoose.models).filter(model => !model.schema[Symbol.for("oon.database.policy.schema.v1")]).length }));
93
125
  await syncDeclaredIndexes();
94
126
  console.log(`Conectado ao MongoDB (${databaseLabel(uri)})`);
95
127
  return mongoose.connection;
@@ -100,6 +132,7 @@ module.exports = {
100
132
  buildMongoUri,
101
133
  connectionOptions,
102
134
  databaseLabel,
135
+ indexSyncMode,
103
136
  shouldSyncDeclaredIndexes,
104
137
  syncDeclaredIndexes,
105
138
  };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Desligamento sem perda para o servidor HTTP do runtime.
5
+ *
6
+ * Em deploy, escala ou exclusão de slot o Kubernetes envia SIGTERM. Sem este
7
+ * tratamento o processo morria na hora, cortando requisições em andamento e
8
+ * tarefas em segundo plano. Aqui o servidor para de aceitar conexões, espera as
9
+ * requisições em andamento (até `timeoutMs`), executa as etapas de drenagem em
10
+ * ordem e só então encerra o processo.
11
+ */
12
+ function installGracefulShutdown(server, {
13
+ drain = [],
14
+ timeoutMs = Number(process.env.OON_SHUTDOWN_TIMEOUT_MS) || 15000,
15
+ signals = ["SIGTERM", "SIGINT"],
16
+ processRef = process,
17
+ log = console,
18
+ } = {}) {
19
+ let shutting = null;
20
+
21
+ const closeServer = () => new Promise((resolve) => {
22
+ if (!server.listening) return resolve();
23
+ server.close(() => resolve());
24
+ // Conexões keep-alive ociosas não podem segurar o fechamento.
25
+ server.closeIdleConnections?.();
26
+ });
27
+
28
+ const shutdown = (signal) => {
29
+ if (shutting) return shutting;
30
+ log.log(`[OonCore] ${signal} recebido; encerrando sem aceitar novas requisições.`);
31
+ let timer;
32
+ const deadline = new Promise((resolve) => {
33
+ timer = setTimeout(() => {
34
+ log.warn(`[OonCore] Encerramento excedeu ${timeoutMs} ms; conexões restantes serão fechadas.`);
35
+ server.closeAllConnections?.();
36
+ resolve("timeout");
37
+ }, timeoutMs);
38
+ timer.unref?.();
39
+ });
40
+ const graceful = (async () => {
41
+ await closeServer();
42
+ for (const step of drain) {
43
+ try {
44
+ await step();
45
+ } catch (error) {
46
+ log.error(`[OonCore] Falha ao drenar durante o encerramento: ${error?.message || error}`);
47
+ }
48
+ }
49
+ return "drained";
50
+ })();
51
+ shutting = Promise.race([graceful, deadline]).then((outcome) => {
52
+ clearTimeout(timer);
53
+ log.log(`[OonCore] Encerramento concluído (${outcome}).`);
54
+ processRef.exit(0);
55
+ return outcome;
56
+ });
57
+ return shutting;
58
+ };
59
+
60
+ for (const signal of signals) processRef.once(signal, () => { void shutdown(signal); });
61
+ return { shutdown };
62
+ }
63
+
64
+ module.exports = { installGracefulShutdown };
@@ -3,6 +3,10 @@ const { connectDB } = require("./database");
3
3
  const { createApp } = require("./createApp");
4
4
  const { loadCentral } = require("./loadCentral");
5
5
  const transactionalEmail = require("../capabilities/transactionalEmail");
6
+ const { startTenantDataPurges, stopTenantDataPurges } = require("../core/runtime/tenantDataPurge");
7
+ const mongoose = require("../core/database/mongoose");
8
+ const { startProcessJobs, stopProcessJobs } = require("../manifest/processRuntime");
9
+ const { installGracefulShutdown } = require("./gracefulShutdown");
6
10
  const {
7
11
  isLocalRuntime,
8
12
  validateLocalRuntime,
@@ -14,12 +18,19 @@ const {
14
18
  * banco, monta a app e escuta a porta. É o `start` da API pública e o
15
19
  * alvo do `oonCore-back start`.
16
20
  *
17
- * @param {{ cwd?: string, listen?: boolean }} opts
21
+ * Com `handleSignals` (padrão), SIGTERM/SIGINT encerram sem perda: o servidor
22
+ * para de aceitar conexões, espera as requisições em andamento, para o worker
23
+ * de e-mail e o da fila de recálculo (as tarefas pendentes ficam no banco
24
+ * para outra réplica) e fecha o banco antes de sair.
25
+ *
26
+ * @param {{ cwd?: string, listen?: boolean, handleSignals?: boolean }} opts
18
27
  */
19
- async function start({ cwd = process.cwd(), listen = true } = {}) {
28
+ async function start({ cwd = process.cwd(), listen = true, handleSignals = true } = {}) {
20
29
  loadCentral(cwd);
21
30
  await connectDB();
22
31
  await transactionalEmail.initialize();
32
+ startTenantDataPurges();
33
+ await startProcessJobs();
23
34
  const host = isLocalRuntime() ? "127.0.0.1" : undefined;
24
35
  validateLocalRuntime({ host: host || process.env.HOST || "127.0.0.1" });
25
36
  const localSession = isLocalRuntime() ? await prepareLocalDevelopmentSession() : null;
@@ -39,8 +50,20 @@ async function start({ cwd = process.cwd(), listen = true } = {}) {
39
50
  }
40
51
  console.log(`${ctx.config.serviceName} rodando na porta ${port} e conectado ao MongoDB`);
41
52
  console.log("****************************************************************");
42
- const stop = () => transactionalEmail.stopWorker();
53
+ const stop = () => { transactionalEmail.stopWorker(); void stopTenantDataPurges(); };
43
54
  server.once("close", stop);
55
+ if (handleSignals) {
56
+ installGracefulShutdown(server, {
57
+ drain: [
58
+ () => transactionalEmail.stopWorker(),
59
+ () => stopProcessJobs(),
60
+ () => stopTenantDataPurges(),
61
+ // F5.3: o consumo ainda em memória vira lote na outbox antes de sair.
62
+ () => require("../core/usage/reporter").stop(),
63
+ () => mongoose.disconnect(),
64
+ ],
65
+ });
66
+ }
44
67
  resolve({ app, server });
45
68
  });
46
69
  });
@@ -17,6 +17,8 @@ function getModels() {
17
17
  DispatchSchema.index({ status: 1, nextRetryAt: 1, leaseExpiresAt: 1 });
18
18
  return { Config: model("OonTransactionalEmailConfig", ConfigSchema, "oon_transactional_email_configs"), Template: model("OonEmailTemplate", TemplateSchema, "oon_email_templates"), Dispatch: model("OonEmailDispatch", DispatchSchema, "oon_email_dispatches") };
19
19
  }
20
- async function syncIndexes() { const models = getModels(); return Promise.all(Object.values(models).map((entry) => entry.syncIndexes())); }
20
+ // Só cria índices que faltam: várias réplicas sobem juntas e a versão anterior
21
+ // ainda pode estar no ar durante o rolling update.
22
+ async function syncIndexes() { const models = getModels(); return Promise.all(Object.values(models).map((entry) => entry.createIndexes())); }
21
23
  module.exports = { getModels, syncIndexes };
22
24
 
@@ -10,13 +10,19 @@ const { renderTemplate } = require("./templates");
10
10
  const state = { running: false, owner: `${process.pid}:${crypto.randomUUID()}`, lastProgressAt: null, timer: null };
11
11
  function backoff(attempt) { const base = Math.min(3600000, 1000 * (2 ** Math.max(0, attempt - 1))); return base + Math.floor(Math.random() * Math.max(250, base * 0.2)); }
12
12
  async function lease() { const { Dispatch } = getModels(); const now = new Date(); return Dispatch.findOneAndUpdate({ status: { $in: ["accepted", "failed", "processing"] }, nextRetryAt: { $lte: now }, $or: [{ leaseExpiresAt: { $exists: false } }, { leaseExpiresAt: null }, { leaseExpiresAt: { $lte: now } }] }, { $set: { status: "processing", leaseOwner: state.owner, leaseExpiresAt: new Date(Date.now() + 60000) }, $inc: { attempts: 1 } }, { new: true, sort: { nextRetryAt: 1, createdAt: 1 } }); }
13
+ // Com várias réplicas, um envio que passe do prazo do lease pode ser assumido
14
+ // por outra réplica. Antes de enviar e ao gravar o resultado, a réplica
15
+ // confirma que ainda é a dona do lease; se não for, desiste sem sobrescrever.
16
+ async function stillOwned(Dispatch, dispatch) { return Boolean(await Dispatch.exists({ _id: dispatch._id, leaseOwner: state.owner, leaseExpiresAt: { $gt: new Date() } })); }
17
+ async function saveIfOwned(Dispatch, dispatch) { const result = await Dispatch.updateOne({ _id: dispatch._id, leaseOwner: state.owner }, dispatch.getChanges()); return Number(result?.matchedCount ?? result?.n ?? 0) > 0; }
13
18
  async function processOne() {
14
19
  const dispatch = await lease(); if (!dispatch) return false;
15
- const { Template } = getModels();
20
+ const { Template, Dispatch } = getModels();
16
21
  try {
17
22
  const config = await configuration({ appCode: dispatch.scopeKey.split(":")[1], tenantId: dispatch.scopeKey.includes(":tenant:") ? dispatch.scopeKey.split(":tenant:")[1] : undefined });
18
23
  const template = await Template.findOne({ scopeKey: dispatch.scopeKey, code: dispatch.templateCode, version: dispatch.templateVersion }).lean();
19
24
  const payload = secrets.decrypt(dispatch.protectedPayload); const rendered = renderTemplate(template, payload.variables); const apiKey = secrets.decrypt(config.encryptedEnvelope);
25
+ if (!await stillOwned(Dispatch, dispatch)) { console.warn(JSON.stringify({ event: "transactionalEmail.worker.lease_lost", stage: "before_send" })); return true; }
20
26
  const result = await providers.sendgrid.send({ to: payload.to, from: { email: config.senderEmail, name: config.senderName }, replyTo: config.replyTo, ...rendered }, apiKey);
21
27
  dispatch.status = "provider_accepted"; dispatch.providerMessageId = result.providerMessageId; dispatch.sentAt = new Date(); dispatch.leaseOwner = undefined; dispatch.leaseExpiresAt = undefined; dispatch.lastError = undefined;
22
28
  } catch (error) {
@@ -24,7 +30,8 @@ async function processOne() {
24
30
  if (normalized.retryable && dispatch.attempts < dispatch.maxAttempts) { dispatch.status = "failed"; dispatch.nextRetryAt = new Date(Date.now() + backoff(dispatch.attempts)); }
25
31
  else { dispatch.status = "dead_letter"; dispatch.failedAt = new Date(); }
26
32
  }
27
- await dispatch.save(); state.lastProgressAt = new Date(); return true;
33
+ if (!await saveIfOwned(Dispatch, dispatch)) console.warn(JSON.stringify({ event: "transactionalEmail.worker.lease_lost", stage: "save" }));
34
+ state.lastProgressAt = new Date(); return true;
28
35
  }
29
36
  async function scrubExpired() { const { Dispatch } = getModels(); await Dispatch.updateMany({ payloadExpiresAt: { $lte: new Date() }, status: { $in: ["provider_accepted", "dead_letter", "cancelled"] } }, { $unset: { protectedPayload: 1 } }); }
30
37
  async function tick() { if (!state.running) return; try { let count = 0; while (state.running && count < 20 && await processOne()) count += 1; if (Math.random() < 0.05) await scrubExpired(); } catch (error) { console.error(JSON.stringify({ event: "transactionalEmail.worker.failed", code: error?.code || "EMAIL_WORKER_FAILED" })); } finally { if (state.running) state.timer = setTimeout(tick, 1000); } }
@@ -10,6 +10,11 @@ const DEFAULTS = Object.freeze({
10
10
  maxTimeMS: 5000,
11
11
  });
12
12
 
13
+ // F4.2: modo de performance por ambiente, escolhido pela plataforma (contexto
14
+ // de delivery → OON_DB_PERFORMANCE_MODE). Só altera maxPoolSize, entre perfis
15
+ // fixos; maxConnecting fica em 2 para limitar a abertura simultânea de conexões.
16
+ const PERFORMANCE_MODES = Object.freeze({ min: 4, normal: 8, ultra: 16 });
17
+
13
18
  function configurationError() {
14
19
  const error = new Error("Política de contenção MongoDB inválida.");
15
20
  error.code = "DATABASE_POLICY_INVALID";
@@ -32,10 +37,21 @@ function poolOptions(policy = DEFAULTS) {
32
37
  return pool;
33
38
  }
34
39
 
40
+ function performancePolicy(mode = "min") {
41
+ if (!Object.hasOwn(PERFORMANCE_MODES, mode)) throw configurationError();
42
+ return validatePolicy({ maxPoolSize: PERFORMANCE_MODES[mode] });
43
+ }
44
+
45
+ /** Política do processo: o modo vem do ambiente (padrão `min`, a contenção atual). */
46
+ function resolvePolicy(env = process.env) {
47
+ const mode = env.OON_DB_PERFORMANCE_MODE === undefined || env.OON_DB_PERFORMANCE_MODE === "" ? "min" : env.OON_DB_PERFORMANCE_MODE;
48
+ return { mode, policy: performancePolicy(mode) };
49
+ }
50
+
35
51
  function boundedMaxTimeMS(value, ceiling = DEFAULTS.maxTimeMS) {
36
52
  if (value === undefined || value === 0) return ceiling;
37
53
  if (!Number.isSafeInteger(value) || value < 0) throw configurationError();
38
54
  return Math.min(value, ceiling);
39
55
  }
40
56
 
41
- module.exports = { DEFAULTS, validatePolicy, poolOptions, boundedMaxTimeMS };
57
+ module.exports = { DEFAULTS, PERFORMANCE_MODES, validatePolicy, poolOptions, performancePolicy, resolvePolicy, boundedMaxTimeMS };
@@ -137,6 +137,23 @@ async function applyEffectiveAccess(usuario) {
137
137
  rbacMode: "app-commercial", commercialAccess: { ...authority,
138
138
  roleComplete: !local.includes("*") && local.every(p => permissions.includes(p)) } };
139
139
  }
140
+ if (usuario?.integrationAccess && typeof ctx.config.auth?.verifyToken !== "function") {
141
+ // F4.4: token de integração validado pela Central. Recebe as permissões do
142
+ // perfil do RBAC do App, sem perfis administrativos nem permissões de
143
+ // gestão de tenant, usuários, perfis ou publicação.
144
+ const source = usuario.integrationAccess;
145
+ const role = roleByCode(source.roleCode);
146
+ if (!source.tokenId || source.tenantId !== usuario.tenantId || !role) {
147
+ throw Object.assign(Error("Token de integração inválido."), { statusCode: 403, code: "INTEGRATION_ACCESS_INVALID" });
148
+ }
149
+ const local = rolePermissions(role);
150
+ if (role.admin === true || local.includes("*")) {
151
+ throw Object.assign(Error("Perfis administrativos não podem ser usados por tokens de integração."), { statusCode: 403, code: "INTEGRATION_ROLE_NOT_ALLOWED" });
152
+ }
153
+ const permissions = local.filter((permission) => !/^(tenant|publications|homologation|production|apps|users|roles)\./.test(permission));
154
+ return { ...usuario, tipo: "integracao", perfil: role.code, roleCode: role.code, permissions, accessStatus: "active",
155
+ mustChangePassword: false, firstAccessPending: false, rbacMode: "integration-token" };
156
+ }
140
157
  if (usuario?.canonicalAccess && typeof ctx.config.auth?.verifyToken !== "function") {
141
158
  const source = usuario.canonicalAccess;
142
159
  const role = roleByCode(source.roleCode);
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Exclusão dos dados de uma organização cuja licença do App terminou há mais
5
+ * de 30 dias (F1.5). A Central decide o que é devido; o runtime só executa o
6
+ * que ela entrega, autenticado pela credencial do deployment.
7
+ *
8
+ * Escopos:
9
+ * - `tenant` (App multi-tenant, runtime compartilhado): apaga os documentos
10
+ * com o tenantId da organização nos models com escopo `tenant` ou `user`, e
11
+ * os e-mails transacionais desse tenant. Models `system`/`legacy`, que não
12
+ * identificam a organização, ficam intactos e saem no relatório.
13
+ * - `full` (App single-tenant, runtime dedicado): apaga os documentos de todos
14
+ * os models do App, exceto os de escopo `system` (configuração do App), além
15
+ * dos e-mails transacionais e dos segredos do runtime. O runtime continua no
16
+ * ar para o próximo licenciamento.
17
+ *
18
+ * Só uma réplica executa por vez (trava no MongoDB).
19
+ */
20
+ const crypto = require("node:crypto");
21
+ const axios = require("axios");
22
+ const mongoose = require("../database/mongoose");
23
+ const registry = require("../registry");
24
+ const ctx = require("../context");
25
+ const { dataScope, tenantField } = require("../tenancy");
26
+ const { getBaseUrl, getTimeout } = require("../utils/activationProvider");
27
+ const { operationalRequestHeaders } = require("../utils/instanceActivation");
28
+ const { isLocalRuntime } = require("./localDevelopment");
29
+
30
+ const PURGES_PATH = "/integracao/runtime/data-purges";
31
+ const LOCKS = "oon_runtime_locks";
32
+ const LOCK_ID = "tenant-data-purge";
33
+ const LOCK_MS = 30 * 60 * 1000;
34
+ const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000;
35
+ const EMAIL_DISPATCHES = "oon_email_dispatches";
36
+ const SECRETS = "oon_secrets";
37
+
38
+ const state = { owner: `${process.pid}:${crypto.randomUUID()}`, timer: null, running: false, inFlight: null };
39
+
40
+ function hasField(entry, field) {
41
+ return Boolean(entry?.mongooseModel?.schema?.path?.(field));
42
+ }
43
+
44
+ async function executePurge({ scope, tenantId }, { entries = registry.listModels(), connection = mongoose.connection } = {}) {
45
+ if (!["tenant", "full"].includes(scope)) throw new Error(`Escopo de exclusão inválido: ${scope}`);
46
+ if (scope === "tenant" && !tenantId) throw new Error("Exclusão por organização exige tenantId.");
47
+ const deleted = {};
48
+ const skipped = [];
49
+ for (const entry of entries) {
50
+ const model = entry?.mongooseModel;
51
+ if (!model) continue;
52
+ const kind = dataScope(entry);
53
+ let filter = null;
54
+ if (scope === "full") {
55
+ if (kind !== "system") filter = {};
56
+ } else if (["tenant", "user"].includes(kind) && hasField(entry, tenantField(entry))) {
57
+ filter = { [tenantField(entry)]: tenantId };
58
+ }
59
+ if (!filter) { skipped.push(entry.name); continue; }
60
+ // eslint-disable-next-line no-await-in-loop
61
+ const result = await model.deleteMany(filter);
62
+ deleted[entry.name] = Number(result?.deletedCount || 0);
63
+ }
64
+ const emails = connection.collection(EMAIL_DISPATCHES);
65
+ const emailFilter = scope === "full" ? {} : { scopeKey: { $regex: `:tenant:${String(tenantId).replace(/[^A-Za-z0-9_-]/g, "")}$` } };
66
+ deleted.emailDispatches = Number((await emails.deleteMany(emailFilter))?.deletedCount || 0);
67
+ if (scope === "full") {
68
+ deleted.secrets = Number((await connection.collection(SECRETS).deleteMany({}))?.deletedCount || 0);
69
+ }
70
+ return { deleted, skipped };
71
+ }
72
+
73
+ async function acquireLock(connection) {
74
+ const locks = connection.collection(LOCKS);
75
+ const expiresAt = new Date(Date.now() + LOCK_MS);
76
+ try {
77
+ await locks.insertOne({ _id: LOCK_ID, owner: state.owner, expiresAt });
78
+ return true;
79
+ } catch (error) {
80
+ if (error?.code !== 11000) throw error;
81
+ }
82
+ const result = await locks.updateOne({ _id: LOCK_ID, expiresAt: { $lte: new Date() } }, { $set: { owner: state.owner, expiresAt } });
83
+ return Number(result?.modifiedCount || 0) > 0;
84
+ }
85
+
86
+ async function releaseLock(connection) {
87
+ await connection.collection(LOCKS).deleteOne({ _id: LOCK_ID, owner: state.owner });
88
+ }
89
+
90
+ async function runOnce({
91
+ http = axios,
92
+ connection = mongoose.connection,
93
+ headers = () => operationalRequestHeaders({}),
94
+ baseUrl = () => getBaseUrl(),
95
+ execute = executePurge,
96
+ log = console,
97
+ } = {}) {
98
+ if (connection?.readyState !== 1) return { skipped: "database_unavailable" };
99
+ if (!await acquireLock(connection)) return { skipped: "locked" };
100
+ try {
101
+ const requestHeaders = await headers();
102
+ const base = baseUrl();
103
+ const response = await http.get(`${base}${PURGES_PATH}`, { headers: requestHeaders, timeout: getTimeout(), maxRedirects: 0 });
104
+ const purges = Array.isArray(response?.data?.purges) ? response.data.purges : [];
105
+ const results = [];
106
+ for (const purge of purges) {
107
+ let body;
108
+ try {
109
+ // eslint-disable-next-line no-await-in-loop
110
+ const report = await execute({ scope: purge.scope, tenantId: purge.tenantId }, { connection });
111
+ body = { ok: true, report };
112
+ log.log(`[OonCore] Dados da organização ${purge.tenantId} excluídos (${purge.scope}).`);
113
+ } catch (error) {
114
+ body = { ok: false, error: String(error?.message || error).slice(0, 500) };
115
+ log.error(`[OonCore] Falha ao excluir dados da organização ${purge.tenantId}: ${body.error}`);
116
+ }
117
+ // eslint-disable-next-line no-await-in-loop
118
+ await http.post(`${base}${PURGES_PATH}/${encodeURIComponent(purge.id)}/complete`, body, { headers: requestHeaders, timeout: getTimeout(), maxRedirects: 0 });
119
+ results.push({ id: purge.id, ok: body.ok });
120
+ }
121
+ return { processed: results };
122
+ } finally {
123
+ await releaseLock(connection);
124
+ }
125
+ }
126
+
127
+ function intervalMs() {
128
+ const value = Number(process.env.OON_DATA_PURGE_INTERVAL_MS);
129
+ return Number.isSafeInteger(value) && value >= 60000 ? value : DEFAULT_INTERVAL_MS;
130
+ }
131
+
132
+ function enabled() {
133
+ if (isLocalRuntime()) return false;
134
+ if (String(process.env.OON_DATA_PURGE || "true").toLowerCase() === "false") return false;
135
+ // A própria Central (root) e Apps globais não recebem licenças de organizações.
136
+ if (ctx.config.ecosystem?.role === "root") return false;
137
+ const tenancy = ctx.config.tenancyModel || ctx.config.ecosystem?.tenancyModel;
138
+ return tenancy !== "none";
139
+ }
140
+
141
+ function schedule(delay) {
142
+ if (!state.running) return;
143
+ state.timer = setTimeout(() => {
144
+ state.inFlight = runOnce()
145
+ .catch((error) => console.warn(`[OonCore] Verificação de exclusões de dados falhou: ${error?.message || error}`))
146
+ .finally(() => { state.inFlight = null; schedule(intervalMs()); });
147
+ }, delay);
148
+ state.timer.unref?.();
149
+ }
150
+
151
+ function startTenantDataPurges() {
152
+ if (state.running || !enabled()) return false;
153
+ state.running = true;
154
+ // Primeira verificação alguns minutos após o boot, com variação entre réplicas.
155
+ schedule(120000 + Math.floor(Math.random() * 60000));
156
+ return true;
157
+ }
158
+
159
+ async function stopTenantDataPurges() {
160
+ state.running = false;
161
+ clearTimeout(state.timer);
162
+ state.timer = null;
163
+ if (state.inFlight) await state.inFlight;
164
+ }
165
+
166
+ module.exports = { executePurge, runOnce, startTenantDataPurges, stopTenantDataPurges, PURGES_PATH, LOCKS };