@oondemand/oon-core-back 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # OonCore Back - ativação de instâncias
2
2
 
3
+ Para criar ou alterar um App, comece pela entrada curta distribuída pelo gerador em `.ooncore/AGENTS.md` e pelo índice `.ooncore/docs/TASK_INDEX.md`. Bootstrap, rotas privadas, permissões, prefixos, erros e `sensitiveBody` estão consolidados em `.ooncore/docs/PUBLIC_CONTRACTS.md`. Esta referência detalhada é para consulta sob demanda.
4
+
3
5
  ## Runtime local desconectado
4
6
 
5
7
  Com `NODE_ENV=development` e `OON_RUNTIME_MODE=local`, o Core escuta apenas em `127.0.0.1`, cria uma sessão técnica local de até 30 dias e não consulta a Central de Ativações. Códigos independentes e de uso único atendem navegador e automação/seed. Apps com tenant usam o contexto virtual fixo `local:tenant`, sem criar um recurso. O estado é `ativa_local`; nenhuma instância, licença, Deployment ou credencial operacional é criada. Produção, Kubernetes, bind externo e identidade operacional falham no startup.
@@ -91,3 +93,7 @@ defineRoutes("/integracao", (router) => {
91
93
  ```
92
94
 
93
95
  O Core omite todo o corpo de entrada e resposta no `requestLog` para esse método e caminho, inclusive se a rota responder erro. Método, URL, status, usuário e correlationId continuam registrados. A opção não cifra o banco nem substitui autorização, validação e respostas sanitizadas. Nunca retorne o segredo pela rota, pelo erro ou pela auditoria da mutação. A opção não se aplica automaticamente a endpoints de CRUD gerados.
96
+
97
+ ## Segredos V1 (0.7.6)
98
+
99
+ `capabilities.secrets.put/status/use/remove` armazena objetos cifrados para Apps single_tenant dedicados. Declare `core.secrets` e `capabilitySettings["core.secrets"]={enabled:true}`. Rotas exigem permissão e sensitiveBody; use `req.accessContext`. Chave provisionada fora do banco, preservada em republicações. Veja o guia distribuído `SECRETS.md` para assinaturas, limites e recadastro explícito.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -5,6 +5,11 @@ const fs = require("node:fs");
5
5
  const path = require("node:path");
6
6
  const registry = require("../core/registry");
7
7
  const ctx = require("../core/context");
8
+ const { mergeScopedFilter } = require("../core/tenancy");
9
+ const { granted } = require("../core/rbac/policy");
10
+ const auth = require("../core/middlewares/auth");
11
+ const GenericError = require("../core/errors/GenericError");
12
+ const { effectiveRoles } = require("../core/middlewares/rbac");
8
13
  const corePackage = require("../../package.json");
9
14
 
10
15
  const router = express.Router();
@@ -115,9 +120,7 @@ const DASHBOARD_FILTER_OPERATORS = Object.freeze({
115
120
  });
116
121
 
117
122
  function dashboardError(message, statusCode = 422) {
118
- const error = new Error(message);
119
- error.statusCode = statusCode;
120
- return error;
123
+ return new GenericError(message, { statusCode, code: "OON_DASHBOARD_REQUEST_REJECTED" });
121
124
  }
122
125
 
123
126
  function dashboardField(entry, field, usage) {
@@ -170,7 +173,7 @@ function normalizeDashboardFilter(entry, raw = {}) {
170
173
  ]));
171
174
  }
172
175
 
173
- async function executeDashboardMetric(entry, query = {}) {
176
+ async function executeDashboardMetric(entry, query = {}, accessContext) {
174
177
  if (!entry?.mongooseModel) throw dashboardError("Model sem persistência para o Dashboard.");
175
178
  const id = String(query.id || "").trim();
176
179
  if (!id) throw dashboardError("Todo indicador do Dashboard deve possuir id.");
@@ -178,7 +181,7 @@ async function executeDashboardMetric(entry, query = {}) {
178
181
  if (!["count", "sum", "groupCount", "groupSum"].includes(kind)) {
179
182
  throw dashboardError(`Agregação de Dashboard não permitida: ${kind}.`);
180
183
  }
181
- const filter = normalizeDashboardFilter(entry, query.filters || {});
184
+ const filter = mergeScopedFilter(entry, normalizeDashboardFilter(entry, query.filters || {}), accessContext);
182
185
 
183
186
  if (kind === "count") {
184
187
  return { id, kind, value: Number(await entry.mongooseModel.countDocuments(filter)) };
@@ -222,13 +225,18 @@ async function executeDashboardMetric(entry, query = {}) {
222
225
  }
223
226
 
224
227
  function canReadDashboardModel(req, entry) {
225
- const roles = entry.definition?.crud?.roles?.read || [];
228
+ if (!req.usuario) return false;
229
+ const crud = entry.definition?.crud || {};
230
+ if (crud.permissions?.read) return granted(req.usuario.permissions, crud.permissions.read);
231
+ const roles = crud.roles?.read || [];
226
232
  if (!roles.length) return true;
227
- const role = req.user?.tipo || req.user?.role;
228
- return roles.includes(role);
233
+ if (req.usuario.commercialAccess && !req.usuario.commercialAccess.roleComplete) return false;
234
+ if (req.usuario.permissions?.includes("*")) return true;
235
+ const allowed = roles.map(role => String(role).trim().toLowerCase());
236
+ return effectiveRoles(req.usuario).some(role => allowed.includes(role));
229
237
  }
230
238
 
231
- router.post("/dashboard/query", async (req, res, next) => {
239
+ router.post("/dashboard/query", auth, async (req, res, next) => {
232
240
  try {
233
241
  const queries = req.body?.queries;
234
242
  if (!Array.isArray(queries) || !queries.length) {
@@ -244,7 +252,7 @@ router.post("/dashboard/query", async (req, res, next) => {
244
252
  if (!canReadDashboardModel(req, entry)) {
245
253
  throw dashboardError(`Sem permissão de leitura para ${entry.name}.`, 403);
246
254
  }
247
- return executeDashboardMetric(entry, query);
255
+ return executeDashboardMetric(entry, query, req.accessContext);
248
256
  }));
249
257
  res.json({ results });
250
258
  } catch (error) {
@@ -70,6 +70,17 @@ function assertConfigBoundary(config, appManifest) {
70
70
  }
71
71
 
72
72
  function loadCentral(cwd = process.cwd()) {
73
+ cwd = path.resolve(cwd);
74
+ // start({ cwd }) always points to the backend, not the App root.
75
+ // Fail locally instead of starting a healthy server with no domain routes.
76
+ if (fs.existsSync(path.join(cwd, APP_MANIFEST_FILENAME))
77
+ && fs.existsSync(path.join(cwd, "backend", "src"))
78
+ && !fs.existsSync(path.join(cwd, "src"))
79
+ && !fs.existsSync(path.join(cwd, "central.config.js"))) {
80
+ const error = new Error(`OON_BACKEND_CWD_INVALID: ${cwd} é a raiz do App. Use start({ cwd: path.join(appRoot, "backend") }) ou execute o CLI em backend/.`);
81
+ error.code = "OON_BACKEND_CWD_INVALID";
82
+ throw error;
83
+ }
73
84
  let appManifest;
74
85
  const appManifestPath = resolveAppManifestPath(cwd);
75
86
  if (fs.existsSync(appManifestPath)) {
@@ -6,6 +6,7 @@ const transactionalEmail = require("./transactionalEmail");
6
6
  const pdf = Object.freeze({ render: pdfRendering.render });
7
7
 
8
8
  module.exports = Object.freeze({
9
+ secrets: require("./secrets"),
9
10
  pdf,
10
11
  pdfRendering: pdf,
11
12
  configurePdfRendering: pdfRendering.configurePdfRendering,
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ const crypto = require("node:crypto");
3
+ const mongoose = require("mongoose");
4
+ const ctx = require("../../core/context");
5
+ const GenericError = require("../../core/errors/GenericError");
6
+ const MAX_BYTES = 16384;
7
+ function fail(code, statusCode = 409) {
8
+ return new GenericError({ SECRETS_UNAVAILABLE: "Restabeleça o provisionamento da chave antes de recadastrar.", SECRETS_REENTRY_REQUIRED: "Recadastre explicitamente a credencial.", SECRETS_NOT_FOUND: "Credencial não cadastrada.", SECRETS_USE_FAILED: "A operação com a credencial falhou." }[code] || "Operação de segredos recusada; confira declaração, contexto e parâmetros.", { code, statusCode });
9
+ }
10
+ function enabled() { return ctx.config.capabilities?.includes("core.secrets"); }
11
+ function resolveScope(context, name) {
12
+ if (!enabled()) throw fail("SECRETS_NOT_DECLARED");
13
+ if (ctx.config.tenancyModel !== "single_tenant" || ctx.config.deploymentMode !== "dedicated") throw fail("SECRETS_UNSUPPORTED_RUNTIME");
14
+ if (typeof name !== "string" || !/^[a-z][a-z0-9._-]{0,63}$/.test(name)) throw fail("SECRETS_INVALID_NAME", 422);
15
+ const { OON_SECRETS_INSTANCE: instance, APP_ENVIRONMENT: environment, OON_SECRETS_TENANT: tenantId } = process.env;
16
+ const appCode = ctx.config.appCode;
17
+ if (!instance || !environment || !tenantId || !appCode) throw fail("SECRETS_UNAVAILABLE");
18
+ if (!context?.userId || context.tenancyModel !== "single_tenant" || context.tenantId !== tenantId || context.appCode !== appCode) throw fail("SECRETS_SCOPE_DENIED", 403);
19
+ return { scope: JSON.stringify([appCode, instance, environment, tenantId]), name };
20
+ }
21
+ function key() {
22
+ const value = process.env.OON_SECRETS_KEY;
23
+ if (!value || !/^[A-Za-z0-9+/]{43}=$/.test(value)) throw fail("SECRETS_UNAVAILABLE");
24
+ const result = Buffer.from(value, "base64");
25
+ if (result.length !== 32 || result.toString("base64") !== value) throw fail("SECRETS_UNAVAILABLE");
26
+ return result;
27
+ }
28
+ function models() {
29
+ if (!mongoose.models.OonSecret) {
30
+ const schema = new mongoose.Schema({ _id: String, scope: String, name: String, envelope: mongoose.Schema.Types.Mixed }, { versionKey: false, timestamps: true });
31
+ mongoose.model("OonSecret", schema, "oon_secrets");
32
+ mongoose.model("OonSecretAudit", new mongoose.Schema({ operation: String, actorId: String, scope: String, name: String, correlationId: String, at: Date }, { versionKey: false }), "oon_secret_audit");
33
+ }
34
+ return { Secret: mongoose.model("OonSecret"), Audit: mongoose.model("OonSecretAudit") };
35
+ }
36
+ function id(s) { return crypto.createHash("sha256").update(JSON.stringify([s.scope, s.name])).digest("hex"); }
37
+ function aad(s) { return Buffer.from(JSON.stringify([1, "AES-256-GCM", s.scope, s.name])); }
38
+ function decode(envelope, s) {
39
+ const k = key();
40
+ let plain;
41
+ try {
42
+ if (envelope?.version !== 1 || envelope.algorithm !== "AES-256-GCM" || !/^[a-f0-9]{24}$/.test(envelope.iv) || !/^[a-f0-9]{32}$/.test(envelope.tag) || typeof envelope.ciphertext !== "string" || envelope.ciphertext.length > MAX_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.ciphertext) || Buffer.from(envelope.ciphertext, "base64").toString("base64") !== envelope.ciphertext) throw Error();
43
+ const cipher = crypto.createDecipheriv("aes-256-gcm", k, Buffer.from(envelope.iv, "hex"));
44
+ cipher.setAAD(aad(s)); cipher.setAuthTag(Buffer.from(envelope.tag, "hex"));
45
+ plain = Buffer.concat([cipher.update(Buffer.from(envelope.ciphertext, "base64")), cipher.final()]);
46
+ return JSON.parse(plain.toString("utf8"));
47
+ } catch { throw fail("SECRETS_REENTRY_REQUIRED"); }
48
+ finally { k.fill(0); plain?.fill(0); }
49
+ }
50
+ async function audit(operation, s, context, correlationId) {
51
+ if (correlationId !== undefined && (typeof correlationId !== "string" || !/^[a-zA-Z0-9:_-]{1,128}$/.test(correlationId))) throw fail("SECRETS_INVALID_CORRELATION", 422);
52
+ await models().Audit.create({ operation, actorId: String(context.userId), ...s, correlationId, at: new Date() });
53
+ }
54
+ async function safe(operation) {
55
+ try { return await operation(); }
56
+ catch (error) { if (error instanceof GenericError && error.code?.startsWith("SECRETS_")) throw error; throw fail("SECRETS_STORAGE_UNAVAILABLE", 503); }
57
+ }
58
+ async function status({ name, context, correlationId } = {}) {
59
+ return safe(async () => {
60
+ const s = resolveScope(context, name);
61
+ await audit("status", s, context, correlationId);
62
+ const row = await models().Secret.findById(id(s)).lean();
63
+ try { const k = key(); k.fill(0); } catch { return { state: "reentry_required", reason: "key_unavailable", action: "restore_provisioning" }; }
64
+ if (!row) return { state: "absent" };
65
+ try { decode(row.envelope, s); return { state: "available" }; }
66
+ catch { return { state: "reentry_required", reason: "incompatible_or_unreadable", action: "replace" }; }
67
+ });
68
+ }
69
+ async function put({ name, value, context, correlationId, replace = false } = {}) {
70
+ return safe(async () => {
71
+ const s = resolveScope(context, name);
72
+ let plain;
73
+ try {
74
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype) throw Error();
75
+ plain = Buffer.from(JSON.stringify(value));
76
+ const parsed = JSON.parse(plain.toString("utf8"));
77
+ if (plain.length > MAX_BYTES || !parsed || Array.isArray(parsed) || typeof parsed !== "object" || !Object.keys(parsed).length) throw Error();
78
+ } catch { plain?.fill(0); throw fail("SECRETS_INVALID_VALUE", 422); }
79
+ let k;
80
+ try {
81
+ k = key();
82
+ const existing = await models().Secret.findById(id(s)).lean();
83
+ if (existing && replace !== true) { try { decode(existing.envelope, s); } catch { throw fail("SECRETS_REENTRY_REQUIRED"); } }
84
+ const iv = crypto.randomBytes(12);
85
+ const cipher = crypto.createCipheriv("aes-256-gcm", k, iv); cipher.setAAD(aad(s));
86
+ const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]).toString("base64");
87
+ const envelope = { version: 1, algorithm: "AES-256-GCM", iv: iv.toString("hex"), tag: cipher.getAuthTag().toString("hex"), ciphertext };
88
+ await audit("put", s, context, correlationId);
89
+ // Deterministic _id makes concurrent first writes use Mongo's unique primary key.
90
+ try { await models().Secret.updateOne({ _id: id(s) }, { $set: { ...s, envelope } }, { upsert: true }); }
91
+ catch (error) { if (error.code !== 11000) throw error; await models().Secret.updateOne({ _id: id(s) }, { $set: { ...s, envelope } }, { upsert: true }); }
92
+ return { state: "available" };
93
+ } finally { k?.fill(0); plain.fill(0); }
94
+ });
95
+ }
96
+ async function use({ name, context, correlationId } = {}, callback) {
97
+ const value = await safe(async () => {
98
+ const s = resolveScope(context, name);
99
+ const row = await models().Secret.findById(id(s)).lean();
100
+ if (!row) throw fail("SECRETS_NOT_FOUND");
101
+ const value = decode(row.envelope, s);
102
+ await audit("use", s, context, correlationId);
103
+ return value;
104
+ });
105
+ try { if (typeof callback !== "function") throw Error(); await callback(value); }
106
+ catch { throw fail("SECRETS_USE_FAILED", 502); }
107
+ finally { for (const name of Object.keys(value)) { try { delete value[name]; } catch { /* consumer may freeze retained copies */ } } }
108
+ // No callback return value crosses this API boundary.
109
+ }
110
+ async function remove({ name, context, correlationId } = {}) {
111
+ return safe(async () => { const s = resolveScope(context, name); await audit("remove", s, context, correlationId); await models().Secret.deleteOne({ _id: id(s) }); return { state: "absent" }; });
112
+ }
113
+ module.exports = Object.freeze({ put, status, use, remove });
@@ -150,13 +150,26 @@ function definePipeline(name, config) {
150
150
  }
151
151
 
152
152
  function defineRoutes(basePath, builder) {
153
+ const invalid = (method, route, reason) => {
154
+ const error = new TypeError(`defineRoutes(${String(basePath)}) ${method} ${String(route)}: ${reason} Use router.private.post("/rota", { permission: "recurso.write" }, handler) ou ("/rota", handler).`);
155
+ error.code = "OON_ROUTE_REGISTRATION_INVALID";
156
+ throw error;
157
+ };
158
+ if (typeof builder !== "function") invalid("builder", "", "builder deve ser uma função.");
153
159
  const expressRouter = express.Router();
154
160
 
155
- const wrapPrivate = (method) => (path, opts = {}, handler) => {
161
+ const wrapPrivate = (method) => (...args) => {
162
+ let [path, opts = {}, handler] = args;
163
+ if (args.length < 2 || args.length > 3) invalid(method, path, "São aceitos dois ou três argumentos.");
164
+ if (typeof opts === "function" && args.length === 3) {
165
+ invalid(method, path, "Middleware na posição de opções descartaria o handler final.");
166
+ }
156
167
  if (typeof opts === "function") {
157
168
  handler = opts;
158
169
  opts = {};
159
170
  }
171
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) invalid(method, path, "Opções devem ser um objeto.");
172
+ if (typeof handler !== "function") invalid(method, path, "Handler ausente ou inválido.");
160
173
  const chain = [auth];
161
174
  const required = opts.permission || opts.permissions;
162
175
  if (required) chain.push(requirePermission(required));
@@ -170,7 +183,9 @@ function defineRoutes(basePath, builder) {
170
183
  expressRouter[method](path, ...chain, asyncHandler(handler));
171
184
  };
172
185
 
173
- const wrapPublic = (method) => (path, handler) => {
186
+ const wrapPublic = (method) => (...args) => {
187
+ const [path, handler] = args;
188
+ if (args.length !== 2 || typeof handler !== "function") invalid(method, path, "Rota pública exige (path, handler).");
174
189
  expressRouter[method](path, asyncHandler(handler));
175
190
  };
176
191
 
@@ -6,6 +6,8 @@ const { kubectl } = require("./command");
6
6
  const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
7
7
  const { provisionMongo, validateMongoUri } = require("./mongoProvisioning");
8
8
 
9
+ const { provisionSecrets } = require("./secretsProvisioning");
10
+
9
11
  const RESOURCE_PROFILES = {
10
12
  nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
11
13
  small: { cpuRequest: "100m", cpuLimit: "500m", memoryRequest: "128Mi", memoryLimit: "512Mi" },
@@ -111,6 +113,11 @@ spec:
111
113
  imagePullPolicy: IfNotPresent
112
114
  ports: [{ name: http, containerPort: 8080 }]
113
115
  envFrom: [{ secretRef: { name: oon-runtime } }]
116
+ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
117
+ - name: OON_SECRETS_KEY
118
+ valueFrom:
119
+ secretKeyRef: { name: oon-core-secrets, key: OON_SECRETS_KEY, optional: true }
120
+ ` : ""}
114
121
  resources:
115
122
  requests: { cpu: ${resources.cpuRequest}, memory: ${resources.memoryRequest} }
116
123
  limits: { cpu: ${resources.cpuLimit}, memory: ${resources.memoryLimit} }
@@ -171,6 +178,10 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
171
178
  CENTRAL_ATIVACAO_API_URL: context.activationApiUrl,
172
179
  INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
173
180
  ...extraSecrets,
181
+ ...(context.functionalCapabilities?.["core.secrets"]?.enabled ? {
182
+ OON_SECRETS_INSTANCE: context.secretsScope.instance,
183
+ OON_SECRETS_TENANT: context.secretsScope.tenantId,
184
+ } : {}),
174
185
  };
175
186
  const args = ["-n", context.namespace, "create", "secret", "generic", "oon-runtime"];
176
187
  for (const [key, value] of Object.entries(literals)) args.push(`--from-literal=${key}=${value}`);
@@ -244,6 +255,7 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
244
255
  "oon-runtime",
245
256
  "INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
246
257
  ) || randomSecret(48);
258
+ provisionSecrets(context);
247
259
  applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
248
260
  return { infra, databaseName, credentialRotated };
249
261
  }
@@ -294,4 +306,4 @@ async function activateMember(context) {
294
306
  return data;
295
307
  }
296
308
 
297
- module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
309
+ module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ const crypto = require("node:crypto");
3
+ const { spawnSync } = require("node:child_process");
4
+ const SECRET = "oon-core-secrets";
5
+ const MARKER = "oon-core-secrets-initialized";
6
+ function command(args, input) {
7
+ const result = spawnSync("kubectl", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
8
+ // Never surface kubectl output: admission errors may reflect the submitted object.
9
+ if (result.error || result.status !== 0) throw Error("SECRETS_PROVISIONING_FAILED: acesso ao cluster recusado ou indisponível.");
10
+ return result.stdout;
11
+ }
12
+ function provisionSecrets(context, run = command) {
13
+ if (!context.functionalCapabilities?.["core.secrets"]?.enabled) return;
14
+ if (!context.secretsScope?.instance || !context.secretsScope?.tenantId || !context.environment) throw Error("SECRETS_SCOPE_REQUIRED");
15
+ const read = (kind, name) => {
16
+ const output = run(["-n", context.namespace, "get", kind, name, "--ignore-not-found", "-o", "json"]);
17
+ try { return output?.trim() ? JSON.parse(output) : null; } catch { throw Error("SECRETS_CLUSTER_RESPONSE_INVALID"); }
18
+ };
19
+ const binding = JSON.stringify([context.appCode, context.secretsScope.instance, context.environment, context.secretsScope.tenantId]);
20
+ let secret = read("secret", SECRET);
21
+ const marker = read("secret", MARKER);
22
+ if (marker && Buffer.from(marker.data?.binding || "", "base64").toString("utf8") !== binding) throw Error("SECRETS_SCOPE_MISMATCH");
23
+ if (!secret) {
24
+ if (marker) throw Error("SECRETS_KEY_MISSING: restabeleça a chave por procedimento controlado.");
25
+ const document = { apiVersion: "v1", kind: "Secret", immutable: true, metadata: { name: SECRET, namespace: context.namespace }, type: "Opaque", stringData: { OON_SECRETS_KEY: crypto.randomBytes(32).toString("base64"), OON_SECRETS_BINDING: binding } };
26
+ // Create, never apply/upsert. A concurrent creator's value wins atomically.
27
+ try { run(["create", "-f", "-"], JSON.stringify(document)); }
28
+ catch { /* A failed create is only recoverable if a valid winner can be read. */ }
29
+ secret = read("secret", SECRET);
30
+ }
31
+ const decode = key => Buffer.from(secret?.data?.[key] || "", "base64").toString("utf8");
32
+ const keyValue = decode("OON_SECRETS_KEY");
33
+ if (!secret || secret.immutable !== true || decode("OON_SECRETS_BINDING") !== binding || !/^[A-Za-z0-9+/]{43}=$/.test(keyValue) || Buffer.from(keyValue, "base64").toString("base64") !== keyValue) throw Error("SECRETS_KEY_INVALID: provisionamento requer intervenção.");
34
+ if (!marker) {
35
+ const doc = { apiVersion: "v1", kind: "Secret", immutable: true, metadata: { name: MARKER, namespace: context.namespace }, type: "Opaque", stringData: { binding } };
36
+ try { run(["create", "-f", "-"], JSON.stringify(doc)); }
37
+ catch { const winner = read("secret", MARKER); if (Buffer.from(winner?.data?.binding || "", "base64").toString("utf8") !== binding) throw Error("SECRETS_MARKER_UNAVAILABLE"); }
38
+ }
39
+ // Never return key material to the executor/context/evidence serializer.
40
+ }
41
+ module.exports = { provisionSecrets };
@@ -147,6 +147,10 @@ function validateCapabilitySettings(settings, capabilities, issues) {
147
147
  addIssue(issues, base, "exige a mesma capability declarada em capabilities.");
148
148
  continue;
149
149
  }
150
+ if (capability === "core.secrets") {
151
+ if (!isPlainObject(config) || config.enabled !== true || Object.keys(config).some(key => key !== "enabled")) addIssue(issues, base, "use apenas { enabled: true }.");
152
+ continue;
153
+ }
150
154
  if (capability !== TRANSACTIONAL_EMAIL_CAPABILITY) {
151
155
  addIssue(issues, base, "capability configurável desconhecida.");
152
156
  continue;
@@ -286,6 +290,10 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
286
290
  });
287
291
  }
288
292
  validateCapabilitySettings(input.capabilitySettings, input.capabilities, issues);
293
+ if (input.capabilities?.includes("core.secrets")) {
294
+ if (dimensions.tenancyModel !== "single_tenant" || dimensions.deploymentMode !== "dedicated") addIssue(issues, "capabilities", "core.secrets V1 exige single_tenant dedicado.");
295
+ if (input.capabilitySettings?.["core.secrets"]?.enabled !== true) addIssue(issues, "capabilitySettings.core.secrets", "declare { enabled: true } para provisionamento.");
296
+ }
289
297
  validateCompatibility(input.compatibility, issues, coreVersion);
290
298
  validateActivation(input.activation, issues);
291
299
  if (input.auth !== undefined) {
@@ -366,7 +366,8 @@ async function assertAtomicInvariants(entry, candidate, current, session) {
366
366
  }
367
367
 
368
368
  const match = {
369
- [rule.parentLocalField]: parentId,
369
+ // Aggregation does not apply Mongoose query casting to HTTP string IDs.
370
+ [rule.parentLocalField]: entry.mongooseModel.schema?.path(rule.parentLocalField)?.cast(parentId) ?? parentId,
370
371
  ...compileMongoMatch(rule.match),
371
372
  };
372
373
  if (current?._id) match._id = { $ne: current._id };
@@ -514,6 +515,9 @@ async function recalculateTarget(entry, selectors, options = {}) {
514
515
  updateOne: {
515
516
  filter: { _id: documents[index]._id },
516
517
  update: { $set: changed },
518
+ // Only server-derived, validated changes reach this recalculation path.
519
+ // readonly still rejects client writes in applyDomainMutation.
520
+ overwriteImmutable: true,
517
521
  },
518
522
  });
519
523
  }