@oondemand/oon-core-back 0.7.16 → 0.7.19
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 +6 -0
- package/bin/cli.js +8 -1
- package/bin/delivery.js +1 -0
- package/package.json +1 -1
- package/src/api/access.routes.js +6 -0
- package/src/api/activation.routes.js +2 -19
- package/src/api/activationRateLimit.js +84 -0
- package/src/boot/database.js +45 -12
- package/src/boot/gracefulShutdown.js +64 -0
- package/src/boot/startServer.js +26 -3
- package/src/capabilities/transactionalEmail/models.js +3 -1
- package/src/capabilities/transactionalEmail/worker.js +9 -2
- package/src/core/database/policy.js +17 -1
- package/src/core/middlewares/auth.js +17 -0
- package/src/core/runtime/tenantDataPurge.js +166 -0
- package/src/core/storage/files.js +131 -0
- package/src/core/usage/reporter.js +193 -0
- package/src/core/utils/instanceActivation.js +20 -10
- package/src/delivery/buildImage.js +3 -0
- package/src/delivery/launchRuntime.js +28 -10
- package/src/delivery/runtime.js +133 -6
- package/src/delivery/supervisor.js +15 -4
- package/src/index.js +4 -0
- package/src/manifest/appManifest.js +4 -1
- package/src/manifest/processJobs.js +252 -0
- package/src/manifest/processRuntime.js +52 -4
- package/src/manifest/usageMetrics.js +39 -0
package/README.md
CHANGED
|
@@ -57,6 +57,12 @@ O comando de delivery preserva `central.app.json` em dois pontos da imagem:
|
|
|
57
57
|
|
|
58
58
|
Centrais legadas sem o manifesto continuam suportadas. O empacotador cria uma pasta intermediária vazia, evitando tornar `central.app.json` obrigatório para aplicações que ainda usam somente `central.config.js`.
|
|
59
59
|
|
|
60
|
+
### `bin/delivery.js deploy` (legado)
|
|
61
|
+
|
|
62
|
+
O subcomando `deploy` de `bin/delivery.js` é legado. Ele provisiona e aplica o runtime diretamente, mas não injeta capabilities no runtime. Não o use em publicações novas; os subcomandos `context` e `state` continuam disponíveis.
|
|
63
|
+
|
|
64
|
+
Na falha de inicialização da imagem, o runtime registra as issues do manifesto, uma por linha, no formato ` - <path>: <mensagem>`, antes da linha `OON_STARTUP_FAILURE`. As mensagens passam pela mesma sanitização do CLI e o stack não é impresso.
|
|
65
|
+
|
|
60
66
|
## Rótulos de campos relacionados
|
|
61
67
|
|
|
62
68
|
Por padrão, campos declarados com `fields.ref(...)` continuam sendo devolvidos pelo CRUD como `ObjectId`. Uma model pode optar pela população segura das referências usadas em grids e cards:
|
package/bin/cli.js
CHANGED
|
@@ -134,4 +134,11 @@ Códigos de saída: ${Object.entries(EXIT_CODES).map(([key, value]) => `${value}
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
main().catch((err) => {
|
|
137
|
+
main().catch((err) => {
|
|
138
|
+
console.error(sanitizeMessage(err?.message || err));
|
|
139
|
+
// Manifest errors (e.g. AppManifestError) carry the actionable detail in issues.
|
|
140
|
+
if (Array.isArray(err?.issues)) {
|
|
141
|
+
for (const issue of err.issues) console.error(` - ${issue?.path || "$"}: ${sanitizeMessage(issue?.message)}`);
|
|
142
|
+
}
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
package/bin/delivery.js
CHANGED
|
@@ -37,6 +37,7 @@ async function main() {
|
|
|
37
37
|
});
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
|
+
// Legado: "deploy" não injeta capabilities no runtime. Ver README.
|
|
40
41
|
if (command === "deploy") {
|
|
41
42
|
const contextFile = required(flag("--context"), "--context");
|
|
42
43
|
const imageDigest = required(flag("--image-digest"), "--image-digest");
|
package/package.json
CHANGED
package/src/api/access.routes.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/boot/database.js
CHANGED
|
@@ -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 {
|
|
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
|
|
70
|
+
return indexSyncMode() !== "off";
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
async function syncDeclaredIndexes(entries = registry.listModels()) {
|
|
57
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 (
|
|
69
|
-
|
|
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
|
-
|
|
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 };
|
package/src/boot/startServer.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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);
|