@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.
@@ -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 };
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * F4.8 — Armazenamento compartilhado de arquivos.
5
+ *
6
+ * Substitui a pasta local `uploads` (que é de cada pod e some no reinício) por
7
+ * GridFS no MongoDB do próprio App: um arquivo gravado por uma réplica é lido
8
+ * por qualquer outra e sobrevive a reinícios e novas publicações. Cada arquivo
9
+ * pertence a um tenant; leitura, listagem e exclusão exigem o mesmo tenant,
10
+ * salvo quando o chamador declara `global: true` (Apps sem tenancy).
11
+ */
12
+
13
+ const { Readable } = require("node:stream");
14
+ const mongoose = require("../database/mongoose");
15
+
16
+ const BUCKET = "oon_files";
17
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
18
+
19
+ function storageError(message, code, statusCode) {
20
+ return Object.assign(new Error(message), { code, statusCode });
21
+ }
22
+
23
+ function bucket(connection = mongoose.connection) {
24
+ if (!connection?.db) throw storageError("Banco de dados indisponível para arquivos.", "FILE_STORAGE_UNAVAILABLE", 503);
25
+ return new mongoose.mongo.GridFSBucket(connection.db, { bucketName: BUCKET });
26
+ }
27
+
28
+ function scope({ tenantId, global = false } = {}) {
29
+ if (global === true) return { global: true, tenantId: null };
30
+ const value = tenantId === undefined || tenantId === null ? "" : String(tenantId);
31
+ if (!value) throw storageError("Tenant obrigatório para arquivos.", "FILE_TENANT_REQUIRED", 400);
32
+ return { global: false, tenantId: value };
33
+ }
34
+
35
+ function objectId(id) {
36
+ try { return new mongoose.Types.ObjectId(String(id)); } catch { throw storageError("Arquivo não encontrado.", "FILE_NOT_FOUND", 404); }
37
+ }
38
+
39
+ function info(doc) {
40
+ return { id: String(doc._id), filename: doc.filename, length: doc.length, contentType: doc.metadata?.contentType || "application/octet-stream",
41
+ tenantId: doc.metadata?.tenantId || null, metadata: doc.metadata?.custom || {}, uploadDate: doc.uploadDate };
42
+ }
43
+
44
+ /** Grava um arquivo (Buffer, string ou Readable) e devolve os metadados. */
45
+ async function saveFile({ data, filename, contentType = "application/octet-stream", metadata = {}, maxBytes = DEFAULT_MAX_BYTES, ...owner } = {}, options = {}) {
46
+ const target = scope(owner);
47
+ const name = String(filename || "").trim();
48
+ if (!name || name.length > 255 || /[\\/\0]/.test(name)) throw storageError("Nome de arquivo inválido.", "FILE_NAME_INVALID", 422);
49
+ const source = data instanceof Readable ? data : Readable.from(Buffer.isBuffer(data) ? [data] : [Buffer.from(String(data ?? ""))]);
50
+ const upload = bucket(options.connection).openUploadStream(name, { metadata: { tenantId: target.tenantId, contentType: String(contentType), custom: metadata } });
51
+ let size = 0;
52
+ await new Promise((resolve, reject) => {
53
+ const fail = (error) => { source.destroy(); upload.abort().catch(() => {}).finally(() => reject(error)); };
54
+ source.on("data", (chunk) => {
55
+ size += chunk.length;
56
+ if (size > maxBytes) return fail(storageError(`Arquivo maior que o limite de ${maxBytes} bytes.`, "FILE_TOO_LARGE", 413));
57
+ if (!upload.write(chunk)) source.pause();
58
+ });
59
+ upload.on("drain", () => source.resume());
60
+ source.on("error", fail);
61
+ upload.on("error", reject);
62
+ upload.on("finish", resolve);
63
+ source.on("end", () => { if (size <= maxBytes) upload.end(); });
64
+ });
65
+ return getFileInfo(upload.id, target, options);
66
+ }
67
+
68
+ async function findScoped(id, owner, options) {
69
+ const target = scope(owner);
70
+ const [doc] = await bucket(options.connection).find({ _id: objectId(id), ...(target.global ? {} : { "metadata.tenantId": target.tenantId }) }).limit(1).toArray();
71
+ if (!doc) throw storageError("Arquivo não encontrado.", "FILE_NOT_FOUND", 404);
72
+ return doc;
73
+ }
74
+
75
+ async function getFileInfo(id, owner = {}, options = {}) {
76
+ return info(await findScoped(id, owner, options));
77
+ }
78
+
79
+ /** Abre o conteúdo para leitura em stream, no escopo do tenant. */
80
+ async function openFile(id, owner = {}, options = {}) {
81
+ const doc = await findScoped(id, owner, options);
82
+ return { file: info(doc), stream: bucket(options.connection).openDownloadStream(doc._id) };
83
+ }
84
+
85
+ async function readFile(id, owner = {}, options = {}) {
86
+ const { file, stream } = await openFile(id, owner, options);
87
+ const chunks = [];
88
+ for await (const chunk of stream) chunks.push(chunk);
89
+ return { file, data: Buffer.concat(chunks) };
90
+ }
91
+
92
+ async function listFiles(owner = {}, { limit = 100, filename } = {}, options = {}) {
93
+ const target = scope(owner);
94
+ const filter = { ...(target.global ? {} : { "metadata.tenantId": target.tenantId }), ...(filename ? { filename: String(filename) } : {}) };
95
+ const docs = await bucket(options.connection).find(filter).sort({ uploadDate: -1 }).limit(Math.min(Math.max(1, Number(limit) || 100), 500)).toArray();
96
+ return docs.map(info);
97
+ }
98
+
99
+ async function deleteFile(id, owner = {}, options = {}) {
100
+ const doc = await findScoped(id, owner, options);
101
+ await bucket(options.connection).delete(doc._id);
102
+ return { deleted: true, id: String(doc._id) };
103
+ }
104
+
105
+ /** Responde a requisição com o arquivo (download ou exibição). */
106
+ async function sendFile(res, id, owner = {}, { download = false } = {}, options = {}) {
107
+ const { file, stream } = await openFile(id, owner, options);
108
+ res.setHeader("Content-Type", file.contentType);
109
+ res.setHeader("Content-Length", String(file.length));
110
+ res.setHeader("X-Content-Type-Options", "nosniff");
111
+ res.setHeader("Content-Disposition", `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(file.filename)}`);
112
+ await new Promise((resolve, reject) => { stream.on("error", reject); res.on("finish", resolve); stream.pipe(res); });
113
+ return file;
114
+ }
115
+
116
+ /** Atalho com o tenant do request autenticado (req.accessContext). */
117
+ function forRequest(req) {
118
+ const tenantId = req?.accessContext?.tenantId;
119
+ const owner = tenantId ? { tenantId } : { global: true };
120
+ return {
121
+ save: (input) => saveFile({ ...input, ...owner }),
122
+ info: (id) => getFileInfo(id, owner),
123
+ open: (id) => openFile(id, owner),
124
+ read: (id) => readFile(id, owner),
125
+ list: (query) => listFiles(owner, query),
126
+ remove: (id) => deleteFile(id, owner),
127
+ send: (res, id, opts) => sendFile(res, id, owner, opts),
128
+ };
129
+ }
130
+
131
+ module.exports = { BUCKET, DEFAULT_MAX_BYTES, saveFile, getFileInfo, openFile, readFile, listFiles, deleteFile, sendFile, forRequest };
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * F5.3/F5.5 — consumo do App para os limites de uso do plano.
5
+ *
6
+ * `usage.record(metric, quantidade, { tenantId })` acumula em memória e, a
7
+ * cada `OON_USAGE_FLUSH_MS` (padrão 15 s), fecha um lote com id único. O lote
8
+ * é gravado na outbox `oon_usage_outbox` do MongoDB do App antes do envio,
9
+ * então sobrevive a reinício e qualquer réplica pode reenviá-lo. A Central
10
+ * conta cada id uma vez só, mesmo se o lote for reenviado.
11
+ *
12
+ * `usage.assertAvailable(metric, { tenantId })` recusa a operação (423
13
+ * USAGE_LIMIT_REACHED) quando o plano bloqueia ao atingir o limite. O estado
14
+ * vem da Central (resposta do lote ou consulta, cache de 60 s) somado ao que
15
+ * ainda não foi enviado. Se a Central não responde, a operação segue: o
16
+ * consumo continua registrado e o limite é aplicado quando ela voltar.
17
+ */
18
+
19
+ const crypto = require("node:crypto");
20
+ const axios = require("axios");
21
+ const mongoose = require("../database/mongoose");
22
+ const { operationalRequestHeaders } = require("../utils/instanceActivation");
23
+ const { getBaseUrl, getTimeout } = require("../utils/activationProvider");
24
+ const { isLocalRuntime } = require("../runtime/localDevelopment");
25
+
26
+ const METRIC = /^[a-z][a-z0-9_.-]{1,62}$/;
27
+ const COLLECTION = "oon_usage_outbox";
28
+ const STATE_TTL_MS = 60 * 1000;
29
+ const LEASE_MS = 30 * 1000;
30
+ const MAX_ATTEMPTS = 20;
31
+ const owner = `${process.pid}:${crypto.randomUUID()}`;
32
+
33
+ const buffers = new Map(); // tenantId -> Map(metric -> quantity)
34
+ const memoryOutbox = []; // sem banco conectado
35
+ const states = new Map(); // tenantId -> { at, metrics: Map }
36
+ let timer = null;
37
+ let flushing = null;
38
+
39
+ function flushInterval() {
40
+ const value = Number(process.env.OON_USAGE_FLUSH_MS);
41
+ return Number.isSafeInteger(value) && value >= 1000 ? value : 15000;
42
+ }
43
+ function usageError(message, code, statusCode, details) {
44
+ return Object.assign(new Error(message), { code, statusCode, ...(details ? { details } : {}) });
45
+ }
46
+ function collection() {
47
+ return mongoose.connection?.readyState === 1 && mongoose.connection.db ? mongoose.connection.db.collection(COLLECTION) : null;
48
+ }
49
+ function key(tenantId) {
50
+ const value = tenantId === undefined || tenantId === null ? "" : String(tenantId);
51
+ if (!value) throw usageError("Tenant obrigatório para registrar consumo.", "USAGE_TENANT_REQUIRED", 400);
52
+ return value;
53
+ }
54
+ function ensureTimer() {
55
+ if (timer) return;
56
+ timer = setInterval(() => { flush().catch(() => {}); }, flushInterval());
57
+ timer.unref?.();
58
+ }
59
+
60
+ /** Registra consumo (acumulado e enviado em lote). */
61
+ function record(metric, quantity = 1, { tenantId } = {}) {
62
+ if (!METRIC.test(String(metric || ""))) throw usageError("Métrica de uso inválida.", "USAGE_METRIC_INVALID", 422, { metric });
63
+ if (!Number.isSafeInteger(quantity) || quantity < 1) throw usageError("Quantidade de uso inválida.", "USAGE_QUANTITY_INVALID", 422);
64
+ if (isLocalRuntime()) return { recorded: false, reason: "local" };
65
+ const tenant = key(tenantId);
66
+ const bucket = buffers.get(tenant) || new Map();
67
+ bucket.set(metric, (bucket.get(metric) || 0) + quantity);
68
+ buffers.set(tenant, bucket);
69
+ ensureTimer();
70
+ return { recorded: true };
71
+ }
72
+
73
+ function pendingFor(tenantId, metric) {
74
+ return buffers.get(String(tenantId))?.get(metric) || 0;
75
+ }
76
+
77
+ /** Fecha os lotes em memória, grava na outbox e envia os pendentes. */
78
+ async function flush() {
79
+ if (flushing) return flushing;
80
+ flushing = (async () => {
81
+ const at = new Date();
82
+ for (const [tenantId, bucket] of [...buffers.entries()]) {
83
+ buffers.delete(tenantId);
84
+ const items = [...bucket.entries()].map(([metric, quantity]) => ({ metric, quantity }));
85
+ if (!items.length) continue;
86
+ const batch = { _id: crypto.randomUUID(), tenantId, items, status: "pending", attempts: 0, nextAttemptAt: at, createdAt: at };
87
+ const outbox = collection();
88
+ if (outbox) await outbox.insertOne(batch); else memoryOutbox.push(batch);
89
+ }
90
+ return deliverPending();
91
+ })().finally(() => { flushing = null; });
92
+ return flushing;
93
+ }
94
+
95
+ async function send(batch) {
96
+ const response = await axios.post(`${getBaseUrl()}/integracao/app-usage/batches`,
97
+ { tenantId: batch.tenantId, batchId: batch._id, items: batch.items },
98
+ { headers: await operationalRequestHeaders({ tenantId: batch.tenantId, correlationId: `usage:${batch._id}` }), timeout: getTimeout(), maxRedirects: 0 });
99
+ remember(batch.tenantId, response.data);
100
+ return response.data;
101
+ }
102
+
103
+ function retryable(error) {
104
+ const status = error?.response?.status;
105
+ return !status || status >= 500 || status === 429 || status === 408;
106
+ }
107
+
108
+ async function deliverPending({ limit = 20 } = {}) {
109
+ const outbox = collection();
110
+ let sent = 0, failed = 0;
111
+ if (!outbox) {
112
+ for (const batch of memoryOutbox.splice(0, limit)) {
113
+ try { await send(batch); sent++; } catch (error) { failed++; if (retryable(error)) memoryOutbox.push(batch); }
114
+ }
115
+ return { sent, failed };
116
+ }
117
+ for (let i = 0; i < limit; i++) {
118
+ const now = new Date();
119
+ const batch = await outbox.findOneAndUpdate(
120
+ { status: "pending", nextAttemptAt: { $lte: now }, $or: [{ leaseUntil: { $exists: false } }, { leaseUntil: { $lt: now } }] },
121
+ { $set: { leaseUntil: new Date(+now + LEASE_MS), leaseOwner: owner } }, { sort: { nextAttemptAt: 1 }, returnDocument: "after" });
122
+ const doc = batch?.value === undefined ? batch : batch.value;
123
+ if (!doc) break;
124
+ try {
125
+ await send(doc);
126
+ await outbox.deleteOne({ _id: doc._id, leaseOwner: owner });
127
+ sent++;
128
+ } catch (error) {
129
+ failed++;
130
+ const attempts = (doc.attempts || 0) + 1;
131
+ const terminal = !retryable(error) || attempts >= MAX_ATTEMPTS;
132
+ await outbox.updateOne({ _id: doc._id, leaseOwner: owner }, {
133
+ $set: { attempts, status: terminal ? "rejected" : "pending", nextAttemptAt: new Date(Date.now() + Math.min(3600000, 2 ** attempts * 1000)),
134
+ lastError: { status: error?.response?.status || null, code: error?.response?.data?.code || error?.code || null, at: new Date() } },
135
+ $unset: { leaseUntil: 1, leaseOwner: 1 } });
136
+ }
137
+ }
138
+ return { sent, failed };
139
+ }
140
+
141
+ function remember(tenantId, data) {
142
+ if (!data || !Array.isArray(data.metrics)) return;
143
+ states.set(String(tenantId), { at: Date.now(), metrics: new Map(data.metrics.map((m) => [m.metric, m])) });
144
+ }
145
+
146
+ async function state(tenantId, { refresh = false } = {}) {
147
+ const tenant = key(tenantId);
148
+ const cached = states.get(tenant);
149
+ if (!refresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached;
150
+ const response = await axios.get(`${getBaseUrl()}/integracao/app-usage/state`, {
151
+ params: { tenantId: tenant }, headers: await operationalRequestHeaders({ tenantId: tenant, correlationId: `usage-state:${crypto.randomUUID()}` }),
152
+ timeout: getTimeout(), maxRedirects: 0 });
153
+ remember(tenant, response.data);
154
+ return states.get(tenant) || { at: Date.now(), metrics: new Map() };
155
+ }
156
+
157
+ /** Recusa a operação quando a métrica bloqueia ao atingir o limite do ciclo. */
158
+ async function assertAvailable(metric, { tenantId, quantity = 1 } = {}) {
159
+ if (isLocalRuntime()) return { available: true, reason: "local" };
160
+ let current;
161
+ try { current = await state(tenantId); } catch { return { available: true, reason: "central_unavailable" }; }
162
+ const limit = current.metrics.get(metric);
163
+ if (!limit || limit.onLimit !== "block") return { available: true };
164
+ const used = (limit.used || 0) + pendingFor(tenantId, metric);
165
+ if (used + quantity > limit.included) {
166
+ throw usageError(`O limite de ${limit.label || metric} do plano foi atingido neste ciclo (${limit.included}). O uso volta no próximo ciclo ou com a troca de plano.`,
167
+ "USAGE_LIMIT_REACHED", 423, { metric, used, included: limit.included, periodEndAt: limit.periodEndAt || null });
168
+ }
169
+ return { available: true, remaining: limit.included - used };
170
+ }
171
+
172
+ /** Middleware: confere o limite antes e registra 1 uso quando a resposta for 2xx. */
173
+ function limit(metric, { quantity = 1 } = {}) {
174
+ return async (req, res, next) => {
175
+ const tenantId = req.accessContext?.tenantId;
176
+ try {
177
+ await assertAvailable(metric, { tenantId, quantity });
178
+ res.on("finish", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { record(metric, quantity, { tenantId }); } catch { /* sem tenant */ } } });
179
+ return next();
180
+ } catch (error) {
181
+ return res.status(error.statusCode || 500).json({ code: error.code, message: error.message, details: error.details });
182
+ }
183
+ };
184
+ }
185
+
186
+ async function stop() {
187
+ if (timer) { clearInterval(timer); timer = null; }
188
+ if (buffers.size) await flush().catch(() => {});
189
+ }
190
+
191
+ function reset() { buffers.clear(); memoryOutbox.length = 0; states.clear(); if (timer) { clearInterval(timer); timer = null; } }
192
+
193
+ module.exports = { COLLECTION, record, flush, deliverPending, state, assertAvailable, limit, stop, reset };
@@ -284,16 +284,26 @@ async function ensureInstance() {
284
284
  );
285
285
 
286
286
  if (!instance) {
287
- instance = await Instancia.create({
288
- ecosystemRole: role(),
289
- status: "nao_ativada",
290
- appCode: getAppCode(),
291
- ambiente: ambiente(),
292
- ecosystemUrl: process.env.CENTRAL_ATIVACAO_URL,
293
- publicUrl: process.env.PUBLIC_APP_URL,
294
- coreVersion: version(),
295
- appVersion: version(),
296
- });
287
+ try {
288
+ instance = await Instancia.create({
289
+ ecosystemRole: role(),
290
+ status: "nao_ativada",
291
+ appCode: getAppCode(),
292
+ ambiente: ambiente(),
293
+ ecosystemUrl: process.env.CENTRAL_ATIVACAO_URL,
294
+ publicUrl: process.env.PUBLIC_APP_URL,
295
+ coreVersion: version(),
296
+ appVersion: version(),
297
+ });
298
+ } catch (error) {
299
+ // Outra réplica criou a instância entre o findOne e o create; o índice
300
+ // único de singletonKey impede a duplicidade, então basta reler.
301
+ if (error?.code !== 11000) throw error;
302
+ instance = await Instancia.findOne().select(
303
+ "+credentialEncrypted +credentialIv +credentialAuthTag +activationRequest",
304
+ );
305
+ if (!instance) throw error;
306
+ }
297
307
  }
298
308
 
299
309
  if (instance.ecosystemRole !== role()) {
@@ -32,6 +32,7 @@ COPY --from=frontend /src/frontend/dist /usr/share/nginx/html
32
32
  COPY .oon-delivery/nginx.conf /etc/nginx/oon.conf
33
33
  COPY .oon-delivery/supervisor.js /supervisor.js
34
34
  COPY .oon-delivery/launchRuntime.js .oon-delivery/validateImage.js /oon-delivery/
35
+ COPY .oon-delivery/errors.js /deploy/contracts/errors.js
35
36
  COPY .oon-delivery/entrypoint.sh /entrypoint.sh
36
37
  RUN chmod +x /entrypoint.sh && mkdir -p /app/backend/uploads /run/nginx && chown -R node:node /app/backend/uploads
37
38
  EXPOSE 8080
@@ -111,6 +112,8 @@ function prepareDeliveryFiles(workspace) {
111
112
  fs.mkdirSync(target, { recursive: true });
112
113
  prepareAppManifest(workspace, target);
113
114
  for (const file of ["supervisor.js", "launchRuntime.js", "validateImage.js"]) fs.copyFileSync(path.join(__dirname, file), path.join(target, file));
115
+ // launchRuntime.js requires ../deploy/contracts/errors (sanitizeMessage).
116
+ fs.copyFileSync(path.join(__dirname, "../deploy/contracts/errors.js"), path.join(target, "errors.js"));
114
117
  fs.writeFileSync(path.join(target, "Dockerfile"), DOCKERFILE);
115
118
  fs.writeFileSync(path.join(target, "nginx.conf"), NGINX);
116
119
  fs.writeFileSync(path.join(target, "entrypoint.sh"), ENTRYPOINT, {
@@ -1,13 +1,31 @@
1
1
  "use strict";
2
2
  const { createRequire } = require("node:module");
3
3
  const { classify } = require("./validateImage");
4
- const runtimeRequire = createRequire("/app/backend/package.json");
5
- // Invoke the public API of the SDK INSIDE the image; do not replace the app's
6
- // dependency version. Include the error class so SyntaxError is not lost by an
7
- // older CLI that prints only error.message. Never print the raw error/stack.
8
- Promise.resolve().then(() => runtimeRequire("@oondemand/oon-core-back").start({ cwd: "/app/backend" }))
9
- .catch((error) => {
10
- const diagnostic = classify(`${error?.name || "Error"}: ${error?.message || ""}`);
11
- console.error(`OON_STARTUP_FAILURE ${JSON.stringify(diagnostic)}`);
12
- process.exit(1);
13
- });
4
+ // Copied next to this file in the image (/deploy/contracts/errors.js) so the
5
+ // same relative path resolves in the repository and inside the image.
6
+ const { sanitizeMessage } = require("../deploy/contracts/errors");
7
+
8
+ // Manifest errors (e.g. AppManifestError) carry the actionable detail in
9
+ // issues. Print only path and sanitized message; never the stack or raw error.
10
+ function startupIssueLines(error) {
11
+ if (!Array.isArray(error?.issues)) return [];
12
+ return error.issues.map((issue) => ` - ${sanitizeMessage(issue?.path || "$")}: ${sanitizeMessage(issue?.message)}`);
13
+ }
14
+
15
+ function launch() {
16
+ const runtimeRequire = createRequire("/app/backend/package.json");
17
+ // Invoke the public API of the SDK INSIDE the image; do not replace the app's
18
+ // dependency version. Include the error class so SyntaxError is not lost by an
19
+ // older CLI that prints only error.message. Never print the raw error/stack.
20
+ Promise.resolve().then(() => runtimeRequire("@oondemand/oon-core-back").start({ cwd: "/app/backend" }))
21
+ .catch((error) => {
22
+ for (const line of startupIssueLines(error)) console.error(line);
23
+ const diagnostic = classify(`${error?.name || "Error"}: ${error?.message || ""}`);
24
+ console.error(`OON_STARTUP_FAILURE ${JSON.stringify(diagnostic)}`);
25
+ process.exit(1);
26
+ });
27
+ }
28
+
29
+ if (require.main === module) launch();
30
+
31
+ module.exports = { startupIssueLines };