@oondemand/oon-core-back 0.4.6 → 0.4.8

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,12 +1,12 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
7
7
  "directory": "packages/oonCore-back"
8
8
  },
9
- "description": "Core runtime para Centrais Oon: Model Registry, CRUD/RBAC automáticos, módulos opinativos (Omie, Assistentes, Multi-moedas, Esteiras, Documentos) e deploy embutido.",
9
+ "description": "Core runtime para Centrais Oon: Model Registry, CRUD/RBAC automáticos, módulos opinativos e capabilities nativas.",
10
10
  "main": "src/index.js",
11
11
  "bin": {
12
12
  "oonCore-back": "bin/cli.js",
@@ -29,7 +29,8 @@
29
29
  "central",
30
30
  "model-registry",
31
31
  "express",
32
- "mongoose"
32
+ "mongoose",
33
+ "capabilities"
33
34
  ],
34
35
  "license": "SEE LICENSE IN LICENSE",
35
36
  "publishConfig": {
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+
3
+ const pdfRendering = require("./pdfRendering");
4
+
5
+ const pdf = Object.freeze({ render: pdfRendering.render });
6
+
7
+ module.exports = Object.freeze({
8
+ pdf,
9
+ pdfRendering: pdf,
10
+ configurePdfRendering: pdfRendering.configurePdfRendering,
11
+ PdfRenderingError: pdfRendering.PdfRenderingError,
12
+ });
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const GenericError = require("../core/errors/GenericError");
5
+
6
+ const PAPER = Object.freeze({
7
+ A3: [11.7, 16.5],
8
+ A4: [8.27, 11.7],
9
+ A5: [5.83, 8.27],
10
+ Letter: [8.5, 11],
11
+ Legal: [8.5, 14],
12
+ });
13
+
14
+ class PdfRenderingError extends GenericError {
15
+ constructor(message, { code = "PDF_RENDERING_ERROR", statusCode = 502, retryable = true, details } = {}) {
16
+ super(message, { code, statusCode, details });
17
+ this.name = "PdfRenderingError";
18
+ this.retryable = retryable;
19
+ }
20
+ }
21
+
22
+ function numberLimit(name, fallback) {
23
+ const value = Number(process.env[name] || fallback);
24
+ return Number.isFinite(value) && value > 0 ? value : fallback;
25
+ }
26
+
27
+ function sanitizeOptions(options = {}) {
28
+ const allowed = new Set(["html", "format", "landscape", "marginTop", "marginBottom", "marginLeft", "marginRight", "printBackground", "timeoutMs"]);
29
+ for (const key of Object.keys(options)) {
30
+ if (!allowed.has(key)) throw new PdfRenderingError(`Opção PDF não suportada: ${key}.`, {
31
+ code: "PDF_RENDERING_INVALID_OPTIONS", statusCode: 422, retryable: false,
32
+ });
33
+ }
34
+ const format = options.format || "A4";
35
+ if (!PAPER[format]) throw new PdfRenderingError(`Formato PDF não suportado: ${format}.`, {
36
+ code: "PDF_RENDERING_INVALID_OPTIONS", statusCode: 422, retryable: false,
37
+ });
38
+ const margin = (key, fallback) => {
39
+ const value = Number(options[key] ?? fallback);
40
+ if (!Number.isFinite(value) || value < 0 || value > 4) throw new PdfRenderingError(`${key} deve estar entre 0 e 4 polegadas.`, {
41
+ code: "PDF_RENDERING_INVALID_OPTIONS", statusCode: 422, retryable: false,
42
+ });
43
+ return value;
44
+ };
45
+ return {
46
+ format,
47
+ landscape: options.landscape === true,
48
+ marginTop: margin("marginTop", 0.39),
49
+ marginBottom: margin("marginBottom", 0.39),
50
+ marginLeft: margin("marginLeft", 0.39),
51
+ marginRight: margin("marginRight", 0.39),
52
+ printBackground: options.printBackground !== false,
53
+ timeoutMs: Math.min(numberLimit("OON_PDF_RENDERING_TIMEOUT_MAX_MS", 60_000), Math.max(1_000, Number(options.timeoutMs || 30_000))),
54
+ };
55
+ }
56
+
57
+ function pdfError(message, options) {
58
+ return new PdfRenderingError(message, options);
59
+ }
60
+
61
+ function providerConfig() {
62
+ const baseUrl = String(process.env.OON_CAPABILITY_PDF_RENDERING_URL || "").replace(/\/+$/, "");
63
+ if (!baseUrl) throw pdfError("A capability pdfRendering não está resolvida neste ambiente.", {
64
+ code: "PDF_RENDERING_UNAVAILABLE", statusCode: 503,
65
+ });
66
+ const username = String(process.env.OON_CAPABILITY_PDF_RENDERING_USERNAME || "");
67
+ const password = String(process.env.OON_CAPABILITY_PDF_RENDERING_PASSWORD || "");
68
+ if (!username || !password) throw pdfError("A credencial interna de pdfRendering não está disponível.", {
69
+ code: "PDF_RENDERING_CREDENTIAL_UNAVAILABLE", statusCode: 503,
70
+ });
71
+ return { baseUrl, username, password };
72
+ }
73
+
74
+ function validatePdf(input) {
75
+ const pdf = Buffer.isBuffer(input) ? input : Buffer.from(input || "");
76
+ const maxBytes = numberLimit("OON_PDF_RENDERING_MAX_OUTPUT_BYTES", 20 * 1024 * 1024);
77
+ if (pdf.length < 8 || pdf.length > maxBytes || !pdf.subarray(0, 5).equals(Buffer.from("%PDF-"))) {
78
+ throw pdfError("O provider retornou um PDF inválido ou acima do limite.", {
79
+ code: "PDF_RENDERING_INVALID_OUTPUT", details: { outputBytes: pdf.length, maxBytes },
80
+ });
81
+ }
82
+ return pdf;
83
+ }
84
+
85
+ function traceContext(context = {}) {
86
+ return {
87
+ correlationId: String(context.correlationId || context.requestId || crypto.randomUUID()).slice(0, 128),
88
+ tenantId: String(context.tenantId || "unknown").slice(0, 128),
89
+ appCode: String(context.appCode || process.env.APP_CODE || "unknown").slice(0, 128),
90
+ environment: String(context.environment || process.env.APP_ENVIRONMENT || "unknown").slice(0, 64),
91
+ integrationTicketId: String(context.integrationTicketId || "").slice(0, 128),
92
+ };
93
+ }
94
+
95
+ function formFor(html, options) {
96
+ const form = new FormData();
97
+ const [width, height] = PAPER[options.format];
98
+ form.append("files", new Blob([html], { type: "text/html; charset=utf-8" }), "index.html");
99
+ form.append("paperWidth", String(width));
100
+ form.append("paperHeight", String(height));
101
+ form.append("landscape", String(options.landscape));
102
+ form.append("marginTop", String(options.marginTop));
103
+ form.append("marginBottom", String(options.marginBottom));
104
+ form.append("marginLeft", String(options.marginLeft));
105
+ form.append("marginRight", String(options.marginRight));
106
+ form.append("printBackground", String(options.printBackground));
107
+ form.append("emulatedMediaType", "print");
108
+ return form;
109
+ }
110
+
111
+ function defaultTransport({ html, options, trace, signal, fetchImpl = globalThis.fetch }) {
112
+ const provider = providerConfig();
113
+ return fetchImpl(`${provider.baseUrl}/forms/chromium/convert/html`, {
114
+ method: "POST",
115
+ headers: {
116
+ authorization: `Basic ${Buffer.from(`${provider.username}:${provider.password}`).toString("base64")}`,
117
+ "Gotenberg-Trace": trace.correlationId,
118
+ "X-Oon-App-Code": trace.appCode,
119
+ "X-Oon-Environment": trace.environment,
120
+ },
121
+ body: formFor(html, options),
122
+ signal,
123
+ });
124
+ }
125
+
126
+ let configuredTransport = defaultTransport;
127
+ let active = 0;
128
+ const queue = [];
129
+
130
+ async function acquire() {
131
+ const max = Math.max(1, Math.min(32, numberLimit("OON_PDF_RENDERING_CONCURRENCY", 4)));
132
+ const maxQueue = Math.max(0, Math.min(1000, numberLimit("OON_PDF_RENDERING_MAX_QUEUE", 20)));
133
+ if (active < max) { active += 1; return; }
134
+ if (queue.length >= maxQueue) throw pdfError("A fila de pdfRendering está saturada.", {
135
+ code: "PDF_RENDERING_SATURATED", statusCode: 503,
136
+ });
137
+ await new Promise((resolve) => queue.push(resolve));
138
+ active += 1;
139
+ }
140
+
141
+ function release() {
142
+ active = Math.max(0, active - 1);
143
+ queue.shift()?.();
144
+ }
145
+
146
+ async function render(input = {}, context = {}) {
147
+ const html = String(input.html || "");
148
+ const maxInput = numberLimit("OON_PDF_RENDERING_MAX_INPUT_BYTES", 2 * 1024 * 1024);
149
+ const inputBytes = Buffer.byteLength(html);
150
+ if (!html.trim() || inputBytes > maxInput) throw pdfError("HTML ausente ou acima do limite de pdfRendering.", {
151
+ code: "PDF_RENDERING_INVALID_INPUT", statusCode: 422, retryable: false,
152
+ details: { inputBytes, maxInput },
153
+ });
154
+ const options = sanitizeOptions(input);
155
+ const trace = traceContext(context);
156
+ const controller = new AbortController();
157
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), options.timeoutMs);
158
+ const abort = () => controller.abort(context.signal.reason);
159
+ context.signal?.addEventListener?.("abort", abort, { once: true });
160
+ const startedAt = Date.now();
161
+ await acquire();
162
+ try {
163
+ const response = await configuredTransport({ html, options, trace, signal: controller.signal, fetchImpl: context.fetchImpl });
164
+ if (!response?.ok) throw pdfError(`Provider de pdfRendering indisponível (HTTP ${response?.status || 502}).`, {
165
+ code: "PDF_RENDERING_PROVIDER_ERROR", statusCode: 502,
166
+ });
167
+ const pdf = validatePdf(Buffer.from(await response.arrayBuffer()));
168
+ console.info(JSON.stringify({ event: "pdfRendering.completed", ...trace, durationMs: Date.now() - startedAt, inputBytes, outputBytes: pdf.length, providerVersion: process.env.OON_CAPABILITY_PDF_RENDERING_VERSION || "unknown" }));
169
+ return pdf;
170
+ } catch (error) {
171
+ const normalized = error instanceof PdfRenderingError ? error : pdfError(
172
+ controller.signal.aborted ? "Tempo limite excedido em pdfRendering." : "Falha transitória ao renderizar PDF.",
173
+ { code: controller.signal.aborted ? "PDF_RENDERING_TIMEOUT" : "PDF_RENDERING_TRANSPORT_ERROR", statusCode: 503 },
174
+ );
175
+ console.error(JSON.stringify({ event: "pdfRendering.failed", ...trace, durationMs: Date.now() - startedAt, inputBytes, code: normalized.code }));
176
+ throw normalized;
177
+ } finally {
178
+ clearTimeout(timer);
179
+ context.signal?.removeEventListener?.("abort", abort);
180
+ release();
181
+ }
182
+ }
183
+
184
+ function configurePdfRendering({ transport } = {}) {
185
+ configuredTransport = transport || defaultTransport;
186
+ }
187
+
188
+ module.exports = { PdfRenderingError, configurePdfRendering, render, sanitizeOptions, validatePdf };
@@ -9,6 +9,7 @@ const SUPPORTED_MAJOR = 1;
9
9
  const RUNTIMES = ["ooncore-node-react"];
10
10
  const RESOURCE_PROFILES = ["nano", "small", "medium", "large"];
11
11
  const REQUIRED = ["schemaVersion", "appCode", "runtime", "components", "resourceProfile", "health"];
12
+ const ROOT_FIELDS = [...REQUIRED, "capabilities"];
12
13
  const COMPONENTS = ["backend", "frontend"];
13
14
  const HEALTH = ["livePath", "readyPath", "versionPath"];
14
15
 
@@ -49,6 +50,29 @@ function noExtra(object, allowed, base, issues) {
49
50
  }
50
51
  }
51
52
 
53
+ function validateCapabilities(capabilities, issues) {
54
+ if (capabilities === undefined) return;
55
+ if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
56
+ issues.push(toIssue("$.capabilities", "capabilities deve ser um objeto."));
57
+ return;
58
+ }
59
+ for (const [name, requirement] of Object.entries(capabilities)) {
60
+ const base = `$.capabilities.${name}`;
61
+ if (!/^[a-z][A-Za-z0-9]{1,63}$/.test(name)) issues.push(toIssue(base, "Nome de capability inválido."));
62
+ if (!requirement || typeof requirement !== "object" || Array.isArray(requirement)) {
63
+ issues.push(toIssue(base, "Requisito da capability deve ser um objeto."));
64
+ continue;
65
+ }
66
+ noExtra(requirement, ["required", "minVersion"], base, issues);
67
+ if (requirement.required !== undefined && typeof requirement.required !== "boolean") {
68
+ issues.push(toIssue(`${base}.required`, "required deve ser booleano."));
69
+ }
70
+ if (requirement.minVersion !== undefined && majorOf(requirement.minVersion) === null) {
71
+ issues.push(toIssue(`${base}.minVersion`, "minVersion deve seguir semver."));
72
+ }
73
+ }
74
+ }
75
+
52
76
  function validateDeployContract(manifest) {
53
77
  const issues = [];
54
78
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
@@ -57,15 +81,12 @@ function validateDeployContract(manifest) {
57
81
  const major = majorOf(manifest.schemaVersion);
58
82
  if (major === null) issues.push(toIssue("$.schemaVersion", "schemaVersion deve seguir semver."));
59
83
  else if (major !== SUPPORTED_MAJOR) throw new ContractError("UNSUPPORTED_CONTRACT_VERSION", `Major não suportado: ${manifest.schemaVersion}. Migre para 1.x.`, { path: "$.schemaVersion" });
60
-
61
84
  REQUIRED.forEach((key) => { if (manifest[key] === undefined) issues.push(toIssue(`$.${key}`, "Campo obrigatório ausente.")); });
62
- noExtra(manifest, REQUIRED, "$", issues);
85
+ noExtra(manifest, ROOT_FIELDS, "$", issues);
63
86
  walkSecrets(manifest, "$", issues);
64
-
65
87
  if (manifest.appCode !== undefined && !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(manifest.appCode)) issues.push(toIssue("$.appCode", "appCode deve ser compatível com DNS/Kubernetes."));
66
88
  if (manifest.runtime !== undefined && !RUNTIMES.includes(manifest.runtime)) issues.push(toIssue("$.runtime", "Runtime não suportado."));
67
89
  if (manifest.resourceProfile !== undefined && !RESOURCE_PROFILES.includes(manifest.resourceProfile)) issues.push(toIssue("$.resourceProfile", "Perfil de recursos não suportado."));
68
-
69
90
  if (manifest.components && typeof manifest.components === "object" && !Array.isArray(manifest.components)) {
70
91
  noExtra(manifest.components, COMPONENTS, "$.components", issues);
71
92
  COMPONENTS.forEach((name) => {
@@ -77,13 +98,13 @@ function validateDeployContract(manifest) {
77
98
  }
78
99
  });
79
100
  } else if (manifest.components !== undefined) issues.push(toIssue("$.components", "components deve ser um objeto."));
80
-
81
101
  if (manifest.health && typeof manifest.health === "object" && !Array.isArray(manifest.health)) {
82
102
  noExtra(manifest.health, HEALTH, "$.health", issues);
83
103
  HEALTH.forEach((key) => {
84
104
  if (typeof manifest.health[key] !== "string" || !manifest.health[key].startsWith("/")) issues.push(toIssue(`$.health.${key}`, "Health path deve iniciar por /."));
85
105
  });
86
106
  } else if (manifest.health !== undefined) issues.push(toIssue("$.health", "health deve ser um objeto."));
107
+ validateCapabilities(manifest.capabilities, issues);
87
108
  }
88
109
  if (issues.length) throw new ContractError("SCHEMA_VALIDATION_ERROR", "oon.deploy.json inválido.", { details: issues });
89
110
  return { ok: true, contract: CONTRACT, version: manifest.schemaVersion, manifest };
@@ -93,4 +114,4 @@ function validateDeployFile(file = path.join(process.cwd(), "oon.deploy.json"))
93
114
  return validateDeployContract(readJsonFile(file));
94
115
  }
95
116
 
96
- module.exports = { validateDeployContract, validateDeployFile, readJsonFile, RUNTIMES, RESOURCE_PROFILES };
117
+ module.exports = { validateDeployContract, validateDeployFile, validateCapabilities, readJsonFile, RUNTIMES, RESOURCE_PROFILES };
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ const { requirePermission } = require("./core/middlewares/permissions");
12
12
  const rbacPolicy = require("./core/rbac/policy");
13
13
  const omieModule = require("./modules/omie");
14
14
  const integrations = require("./modules/integrations");
15
+ const capabilities = require("./capabilities");
15
16
  const appManifest = require("./manifest/appManifest");
16
17
  const domainManifest = require("./manifest/domainManifest");
17
18
  const domainRules = require("./manifest/domainRules");
@@ -20,35 +21,20 @@ const processRuntime = require("./manifest/processRuntime");
20
21
  const referencePolicies = require("./manifest/referencePolicies");
21
22
 
22
23
  module.exports = {
23
- start,
24
- createApp,
25
- activate,
26
- defineCentral: define.defineCentral,
27
- defineModel: define.defineModel,
28
- defineCollection: define.defineCollection,
29
- defineDocument: define.defineDocument,
30
- definePipeline: define.definePipeline,
31
- defineOmieMapping: define.defineOmieMapping,
32
- defineRoutes: define.defineRoutes,
33
- defineValidation: define.defineValidation,
24
+ start, createApp, activate,
25
+ defineCentral: define.defineCentral, defineModel: define.defineModel,
26
+ defineCollection: define.defineCollection, defineDocument: define.defineDocument,
27
+ definePipeline: define.definePipeline, defineOmieMapping: define.defineOmieMapping,
28
+ defineRoutes: define.defineRoutes, defineValidation: define.defineValidation,
34
29
  defineTrigger: define.defineTrigger,
35
-
36
- requirePermission,
37
- CORE_PERMISSIONS: rbacPolicy.CORE_PERMISSIONS,
38
- rbacPolicy: rbacPolicy.policy,
39
- roleByCode: rbacPolicy.roleByCode,
30
+ requirePermission, CORE_PERMISSIONS: rbacPolicy.CORE_PERMISSIONS,
31
+ rbacPolicy: rbacPolicy.policy, roleByCode: rbacPolicy.roleByCode,
40
32
  permissionGranted: rbacPolicy.granted,
41
-
42
- TENANT_HEADER: tenancy.TENANT_HEADER,
43
- TENANCY_MODELS: tenancy.TENANCY_MODELS,
44
- DATA_SCOPES: tenancy.DATA_SCOPES,
45
- createAccessContext: tenancy.createAccessContext,
46
- readRequestedTenantId: tenancy.readRequestedTenantId,
47
- scopeFilter: tenancy.scopeFilter,
48
- mergeScopedFilter: tenancy.mergeScopedFilter,
49
- scopeMutation: tenancy.scopeMutation,
33
+ TENANT_HEADER: tenancy.TENANT_HEADER, TENANCY_MODELS: tenancy.TENANCY_MODELS,
34
+ DATA_SCOPES: tenancy.DATA_SCOPES, createAccessContext: tenancy.createAccessContext,
35
+ readRequestedTenantId: tenancy.readRequestedTenantId, scopeFilter: tenancy.scopeFilter,
36
+ mergeScopedFilter: tenancy.mergeScopedFilter, scopeMutation: tenancy.scopeMutation,
50
37
  scopedIdFilter: tenancy.scopedIdFilter,
51
-
52
38
  APP_MANIFEST_FILENAME: appManifest.APP_MANIFEST_FILENAME,
53
39
  APP_MANIFEST_SCHEMA_VERSION: appManifest.APP_MANIFEST_SCHEMA_VERSION,
54
40
  SUPPORTED_APP_KINDS: appManifest.SUPPORTED_APP_KINDS,
@@ -59,7 +45,6 @@ module.exports = {
59
45
  appManifestToConfig: appManifest.appManifestToConfig,
60
46
  registerAppManifest: appManifest.registerAppManifest,
61
47
  loadAppManifest: appManifest.loadAppManifest,
62
-
63
48
  DOMAIN_MANIFEST_FILENAME: domainManifest.DOMAIN_MANIFEST_FILENAME,
64
49
  DOMAIN_MANIFEST_SCHEMA_VERSION: domainManifest.DOMAIN_MANIFEST_SCHEMA_VERSION,
65
50
  SUPPORTED_DOMAIN_FIELD_KINDS: domainManifest.SUPPORTED_FIELD_KINDS,
@@ -74,7 +59,6 @@ module.exports = {
74
59
  applyDomainMutation: domainRules.applyDomainMutation,
75
60
  validateDomainRecord: domainRules.validateDomainRecord,
76
61
  detectChangedFields: domainRules.detectChangedFields,
77
-
78
62
  PROCESS_MANIFEST_FILENAME: processManifest.PROCESS_MANIFEST_FILENAME,
79
63
  PROCESS_MANIFEST_SCHEMA_VERSION: processManifest.PROCESS_MANIFEST_SCHEMA_VERSION,
80
64
  ProcessManifestError: processManifest.ProcessManifestError,
@@ -88,10 +72,7 @@ module.exports = {
88
72
  assertDeleteAllowed: processRuntime.assertDeleteAllowed,
89
73
  recalculateDependents: processRuntime.recalculateDependents,
90
74
  drainProcessJobs: processRuntime.drainProcessJobs,
91
-
92
- fields,
93
- omie: omieModule.omie,
94
- omieModule,
75
+ fields, omie: omieModule.omie, omieModule,
95
76
  createOmieClient: omieModule.createOmieClient,
96
77
  OmieApiError: omieModule.OmieApiError,
97
78
  enqueueOmieCall: omieModule.enqueueOmieCall,
@@ -101,10 +82,10 @@ module.exports = {
101
82
  saveOmieConfiguration: omieModule.saveOmieConfiguration,
102
83
  listOmieConfigurations: omieModule.listOmieConfigurations,
103
84
  testOmieConnection: omieModule.testOmieConnection,
104
- integrations,
105
- registerIntegrationProvider: integrations.registerIntegrationProvider,
85
+ integrations, registerIntegrationProvider: integrations.registerIntegrationProvider,
106
86
  enqueueIntegration: integrations.enqueueIntegration,
107
87
  receiveIntegrationWebhook: integrations.receiveWebhook,
108
- GenericError,
109
- registry,
88
+ capabilities,
89
+ PdfRenderingError: capabilities.PdfRenderingError,
90
+ GenericError, registry,
110
91
  };