@oondemand/oon-core-back 0.7.4 → 0.7.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/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,38 @@ 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.
100
+
101
+
102
+ ### Aceite de convite (C2, SDK 0.7.7)
103
+
104
+ `PUBLIC_APP_URL` deve conter a origem pública HTTPS do aplicativo, sem credenciais,
105
+ query, fragmento ou subcaminho. HTTP é permitido apenas em loopback fora de produção.
106
+ A configuração é validada antes de emitir/rotacionar o convite; o header Host não
107
+ é usado como destino. Novos links usam `/accept-invite#token=...`.
108
+
109
+ O frontend consulta `POST /core/invitations/inspect` com `{ token }`. Somente um
110
+ convite pendente válido permite consultar a existência da identidade. O aceite
111
+ usa `POST /core/invitations/accept` com `{ token, intent, password? }`:
112
+ `authenticate` verifica a senha atual; `register` cria uma identidade com senha
113
+ nova; `session` exige uma sessão válida do destinatário. Nenhum desses caminhos
114
+ redefine a senha de uma identidade existente. Recuperação de senha continua no
115
+ login da plataforma. O retorno após aceite é local e fixo, sem aceitar redirect
116
+ arbitrário. Os endpoints antigos continuam disponíveis até o cutover coordenado.
117
+
118
+ Provedores locais devem fornecer `auth.inspectInvitation({ req, invitation })`,
119
+ retornando `{ accountExists: boolean, sessionUser?: { id, email } | null }`, além de
120
+ `auth.provisionInvitation`. A sessão opcional deve ser validada como identidade
121
+ (assinatura, expiração, versão de credencial e escopo), sem exigir o acesso que
122
+ o convite ainda concederá. Uma sessão inválida retorna `sessionUser: null`.
123
+ O provisionamento recebe também `intent` e deve verificar a prova correspondente,
124
+ retornando `usuario` com `id` e `email` do destinatário. Sem hook de inspeção,
125
+ um provedor local falha fechado e nunca delega identidades administrativas ao
126
+ provedor de aplicativos.
127
+
128
+ Publicar primeiro o suporte da Central (consulta e prova de identidade), depois
129
+ lançar front/back do SDK juntos e atualizar consumidores. O bloqueio de integração
130
+ comercial permanece até C3a/C3b; C2 não executa cutover nem reenvia convites.
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.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -22,9 +22,10 @@ const {
22
22
  recoverPendingInvitation,
23
23
  assertNotLastAdmin,
24
24
  } = require("../core/rbac/storage");
25
- const { provisionInvitation } = require("../core/rbac/identityProvider");
25
+ const { provisionInvitation, inspectInvitation } = require("../core/rbac/identityProvider");
26
26
 
27
27
  const router = express.Router();
28
+ router.use(require("../core/rbac/centralInvitations").createRouter({ inviteUrl }));
28
29
 
29
30
  function requiredRbac() {
30
31
  if (!policy().enabled) {
@@ -36,10 +37,16 @@ function requiredRbac() {
36
37
  }
37
38
 
38
39
  function inviteUrl(req, token) {
39
- const base = String(
40
- process.env.PUBLIC_APP_URL || `${req.protocol || "http"}://${req.get("host") || req.headers.host}`,
41
- ).replace(/\/$/, "");
42
- return `${base}/accept-invite?token=${encodeURIComponent(token)}`;
40
+ let base;
41
+ try {
42
+ base = new URL(String(process.env.PUBLIC_APP_URL || ""));
43
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
44
+ if ((base.protocol !== "https:" && !(base.protocol === "http:" && local && process.env.NODE_ENV !== "production"))
45
+ || base.username || base.password || base.search || base.hash || base.pathname !== "/") throw new Error();
46
+ } catch {
47
+ throw new GenericError("Configure PUBLIC_APP_URL com a origem HTTPS pública deste aplicativo.", { statusCode: 503, code: "INVITATION_PUBLIC_URL_INVALID" });
48
+ }
49
+ return `${base.origin}/accept-invite#token=${encodeURIComponent(token)}`;
43
50
  }
44
51
 
45
52
  function invitationView(invitation) {
@@ -139,6 +146,7 @@ router.post(
139
146
  asyncHandler(async (req, res) => {
140
147
  requiredRbac();
141
148
  const role = assertAssignable(req.usuario, req.body?.roleCode);
149
+ inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
142
150
  const result = await createInvitation({
143
151
  email: req.body?.email,
144
152
  name: req.body?.name,
@@ -192,6 +200,7 @@ router.post(
192
200
  const previous = await Invitation.findOne({ _id: req.params.id, appCode: appCode() });
193
201
  if (!previous) throw new GenericError("Convite não encontrado.", { statusCode: 404 });
194
202
  assertAssignable(req.usuario, previous.roleCode);
203
+ inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
195
204
  const result = await createInvitation({
196
205
  email: previous.email,
197
206
  name: previous.name,
@@ -277,11 +286,9 @@ router.patch(
277
286
  }),
278
287
  );
279
288
 
280
- router.get(
281
- "/invitations/:token",
282
- asyncHandler(async (req, res) => {
289
+ const inspectHandler = asyncHandler(async (req, res) => {
283
290
  requiredRbac();
284
- const invitation = await findInvitationByToken(req.params.token);
291
+ const invitation = await findInvitationByToken(req.params.token || req.body?.token);
285
292
  if (!invitation || invitation.status !== "pending") {
286
293
  throw new GenericError("Convite inválido, expirado ou já utilizado.", {
287
294
  statusCode: 410,
@@ -289,8 +296,12 @@ router.get(
289
296
  });
290
297
  }
291
298
  res.set("Cache-Control", "no-store");
299
+ const identity = await inspectInvitation({ req, invitation });
300
+ if (typeof identity?.accountExists !== "boolean") throw new GenericError("Resposta inválida do provedor de identidade.", { statusCode: 502 });
292
301
  res.json({
293
302
  invitation: {
303
+ accountExists: identity.accountExists,
304
+ sessionUser: identity.sessionUser || null,
294
305
  email: invitation.email,
295
306
  name: invitation.name,
296
307
  roleCode: invitation.roleCode,
@@ -298,19 +309,23 @@ router.get(
298
309
  expiresAt: invitation.expiresAt,
299
310
  },
300
311
  });
301
- }),
302
- );
312
+ });
313
+ router.get("/invitations/:token", inspectHandler);
314
+ router.post("/invitations/inspect", inspectHandler);
303
315
 
304
- router.post(
305
- "/invitations/:token/accept",
306
- asyncHandler(async (req, res) => {
316
+ const acceptHandler = asyncHandler(async (req, res) => {
307
317
  requiredRbac();
308
318
  const password = String(req.body?.password || "");
309
- if (password.length < 8) {
310
- throw new GenericError("A nova senha deve possuir pelo menos 8 caracteres.", { statusCode: 422 });
319
+ const intent = req.body?.intent;
320
+ if (intent !== undefined && !["register", "authenticate", "session"].includes(intent)) {
321
+ throw new GenericError("Forma de autenticação inválida.", { statusCode: 422 });
322
+ }
323
+ if ((intent === "register" || intent === undefined) && password.length < 8) {
324
+ throw new GenericError("A senha de cadastro deve possuir pelo menos 8 caracteres.", { statusCode: 422 });
311
325
  }
326
+ if (intent !== "session" && !password) throw new GenericError("Informe a senha.", { statusCode: 422 });
312
327
 
313
- const invitation = await findInvitationByToken(req.params.token);
328
+ const invitation = await findInvitationByToken(req.params.token || req.body?.token);
314
329
  if (!invitation || invitation.status !== "pending") {
315
330
  throw new GenericError("Convite inválido, expirado ou já utilizado.", {
316
331
  statusCode: 410,
@@ -324,8 +339,12 @@ router.post(
324
339
  throw new GenericError("O acesso associado ao convite não está mais disponível.", { statusCode: 410 });
325
340
  }
326
341
 
327
- const identity = await provisionInvitation({ req, invitation, password });
342
+ const identity = await provisionInvitation({ req, invitation, password, intent });
328
343
  const providerUser = identity?.usuario || identity?.user || identity;
344
+ if (!providerUser?.email || String(providerUser.email).trim().toLowerCase() !== invitation.email.toLowerCase()
345
+ || !(providerUser.id || providerUser._id)) {
346
+ throw new GenericError("Identidade retornada não corresponde ao convite.", { statusCode: 502, code: "INVITATION_IDENTITY_MISMATCH" });
347
+ }
329
348
  access.identityId = String(providerUser?.id || providerUser?._id || access.identityId || "") || undefined;
330
349
  access.name = providerUser?.nome || providerUser?.name || access.name;
331
350
  access.status = "active";
@@ -347,11 +366,12 @@ router.post(
347
366
 
348
367
  res.set("Cache-Control", "no-store");
349
368
  res.json({
350
- message: "Convite aceito. A nova senha foi definida e o acesso está ativo.",
369
+ message: "Convite aceito. O acesso está ativo.",
351
370
  email: access.email,
352
371
  });
353
- }),
354
- );
372
+ });
373
+ router.post("/invitations/:token/accept", acceptHandler);
374
+ router.post("/invitations/accept", acceptHandler);
355
375
 
356
376
  module.exports = router;
357
377
  module.exports.inviteUrl = inviteUrl;
@@ -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
 
@@ -137,6 +137,14 @@ 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?.canonicalAccess && typeof ctx.config.auth?.verifyToken !== "function") {
141
+ const source = usuario.canonicalAccess;
142
+ const role = roleByCode(source.roleCode);
143
+ if (!source.grantId || source.tenantId !== usuario.tenantId || !role) {
144
+ throw Object.assign(Error("Concessão central inválida."), { statusCode: 403, code: "CENTRAL_ACCESS_INVALID" });
145
+ }
146
+ return { ...usuario, roleCode: role.code, permissions: rolePermissions(role), accessStatus: "active", mustChangePassword: false, rbacMode: "central-invitation" };
147
+ }
140
148
  const access = await resolveUserAccess(usuario);
141
149
  return {
142
150
  ...usuario,
@@ -8,10 +8,11 @@ const registry = require("../registry");
8
8
  const requestLog = async (req, res, next) => {
9
9
  if (req.method === "GET") return next();
10
10
 
11
- const sensitiveBody = await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
11
+ const invitationRequest = /\/(?:core\/invitations|convites)\//.test(String(req.originalUrl || req.url));
12
+ const sensitiveBody = invitationRequest || await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
12
13
  const log = new Log({
13
14
  usuario: req.usuario || null,
14
- endpoint: req.originalUrl,
15
+ endpoint: invitationRequest ? String(req.originalUrl).replace(/(\/core\/invitations\/)[^/?]+/, "$1[redacted]").split("?")[0] : req.originalUrl,
15
16
  metodo: req.method,
16
17
  ip: req.ip || req.headers["x-forwarded-for"] || req.connection?.remoteAddress,
17
18
  correlationId: req.correlationId,
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ const express = require("express");
3
+ const ctx = require("../context");
4
+ const auth = require("../middlewares/auth");
5
+ const { asyncHandler } = require("../utils/helpers");
6
+ const GenericError = require("../errors/GenericError");
7
+ const { defaultProviderCall } = require("./identityProvider");
8
+ const { models, appCode, audit } = require("./storage");
9
+ const base = "/integracao/autenticacao-aplicativos/convites/v1";
10
+ function enabled() { return process.env.OON_CENTRAL_INVITATIONS_ENABLED === "true" && typeof ctx.config.auth?.provisionInvitation !== "function" && typeof ctx.config.auth?.verifyToken !== "function" && process.env.OON_RUNTIME_MODE !== "local"; }
11
+ async function cutover() {
12
+ // No import or token recovery. Idempotent state transition touches only old pending invitations.
13
+ const { Invitation } = models();
14
+ const session = await Invitation.db.startSession();
15
+ try { await session.withTransaction(async () => {
16
+ const rows = await Invitation.find({ appCode: appCode(), status: "pending" }).session(session);
17
+ for (const row of rows) {
18
+ const changed = await Invitation.updateOne({ _id: row._id, status: "pending" }, { $set: { status: "revoked", revokedAt: new Date() } }, { session });
19
+ if (changed.modifiedCount) await audit("invitation.cutover.v1", { actor: "system:cutover", target: row.email, context: { invitationId: String(row._id), cutoverId: "central-invitations-v1" }, session });
20
+ }
21
+ }); } finally { await session.endSession(); }
22
+ }
23
+ function createRouter({ inviteUrl }) {
24
+ const router = express.Router();
25
+ router.use((req, _res, next) => enabled() ? next() : next("router"));
26
+ const call = (req, payload) => defaultProviderCall(req, `${base}/command`, payload);
27
+ const management = handler => [auth, asyncHandler(async (req, res) => { await call(req, { command: "list" }); await cutover(); res.set("Cache-Control", "no-store"); await handler(req, res); })];
28
+ function identifier(value) {
29
+ const match = /^central:([a-f\d]{24})$/i.exec(String(value));
30
+ if (!match) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
31
+ return match[1];
32
+ }
33
+ function invitation(row) { return { ...row, id: `central:${row.id}` }; }
34
+ const access = row => ({ id: `central:${row.id}`, email: row.email, name: row.name, roleCode: row.roleCode, status: row.status === "accepted" ? "active" : row.status === "pending" ? "invited" : "revoked", origin: "central", invitation: invitation(row) });
35
+ async function withLink(req, result) {
36
+ if (result.invitation.status && result.invitation.status !== "pending") return { invitation: invitation(result.invitation), access: access(result.invitation) };
37
+ const copied = await call(req, { command: "copy", id: result.invitation.id });
38
+ return { invitation: invitation(copied.invitation), access: access(copied.invitation), inviteUrl: inviteUrl(req, copied.token) };
39
+ }
40
+ router.get("/users", ...management(async (req, res) => {
41
+ const result = await call(req, { command: "list" });
42
+ const canonical = result.invitations.map(access);
43
+ const identities = new Set(canonical.map(row => row.email));
44
+ const local = req.usuario?.commercialAccess ? [] : await models().UserAccess.find({ appCode: appCode(), status: { $ne: "invited" } }).lean();
45
+ res.json({ results: [...canonical, ...local.filter(row => !identities.has(row.email)).map(row => ({ id: String(row._id), email: row.email, name: row.name, roleCode: row.roleCode, status: row.status, origin: "local" }))] });
46
+ }));
47
+ router.post("/users/invitations", ...management(async (req, res) => {
48
+ inviteUrl(req, "");
49
+ const result = await call(req, { command: "create", email: req.body?.email, roleCode: req.body?.roleCode, version: 0 });
50
+ res.status(201).json(await withLink(req, result));
51
+ }));
52
+ router.get("/users/invitations/:id/link", ...management(async (req, res) => res.json(await withLink(req, { invitation: { id: identifier(req.params.id) } }))));
53
+ router.post("/users/invitations/:id/regenerate", ...management(async (req, res) => {
54
+ inviteUrl(req, "");
55
+ res.json(await withLink(req, await call(req, { command: "reissue", id: identifier(req.params.id), version: req.body?.version })));
56
+ }));
57
+ router.delete("/users/invitations/:id", ...management(async (req, res) => res.json(await call(req, { command: "revoke", id: identifier(req.params.id), version: req.body?.version }))));
58
+ router.patch("/users/:id", auth, asyncHandler(async (req, _res, next) => { if (!String(req.params.id).startsWith("central:")) return next("router"); throw new GenericError("Gerencie a concessão na Central; esta lista é uma projeção de leitura.", { statusCode: 409, code: "CENTRAL_ACCESS_COMMAND_REQUIRED" }); }));
59
+ for (const action of ["inspect", "accept", "decline"]) router.post(`/invitations/${action}`, asyncHandler(async (req, res) => {
60
+ if (!String(req.body?.token || "").startsWith("c3.")) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
61
+ res.set("Cache-Control", "no-store");
62
+ if (action === "inspect") return res.json(await defaultProviderCall(req, `${base}/inspect`, { token: req.body.token }));
63
+ const result = await call(req, { command: action, token: req.body.token, version: req.body.version, intent: req.body.intent, password: req.body.password, registration: req.body.registration });
64
+ res.json({ ...result, message: "Convite processado pela Central." });
65
+ }));
66
+ router.all("/invitations/:token/accept", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
67
+ router.get("/invitations/:token", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
68
+ return router;
69
+ }
70
+ module.exports = { enabled, cutover, createRouter };
@@ -10,6 +10,7 @@ const {
10
10
  } = require("../utils/instanceActivation");
11
11
 
12
12
  const IDENTITY_PATHS = Object.freeze({
13
+ inspectInvitation: "/integracao/autenticacao-aplicativos/convites/inspecionar",
13
14
  provisionInvitation: "/integracao/autenticacao-aplicativos/convites/provisionar",
14
15
  changeFirstAccessPassword: "/integracao/autenticacao-aplicativos/primeiro-acesso/senha",
15
16
  });
@@ -25,6 +26,9 @@ async function defaultProviderCall(req, path, payload) {
25
26
 
26
27
  try {
27
28
  const headers = instanceHeaders(instance);
29
+ for (const key of ["idempotency-key", "x-correlation-id", "x-oon-tenant-id"]) {
30
+ if (req?.headers?.[key]) headers[key] = req.headers[key];
31
+ }
28
32
  if (req?.headers?.authorization) headers.Authorization = req.headers.authorization;
29
33
  const response = await providerPost(req, path, payload, headers);
30
34
  return response.data;
@@ -38,14 +42,24 @@ async function defaultProviderCall(req, path, payload) {
38
42
  }
39
43
  }
40
44
 
41
- async function provisionInvitation({ req, invitation, password }) {
45
+ async function inspectInvitation({ req, invitation }) {
46
+ const hook = ctx.config.auth?.inspectInvitation;
47
+ if (typeof hook === "function") return hook({ req, invitation });
48
+ if (typeof ctx.config.auth?.provisionInvitation === "function") {
49
+ throw new GenericError("O provedor local precisa disponibilizar a consulta de convite.", { statusCode: 503, code: "INVITATION_INSPECTION_REQUIRED" });
50
+ }
51
+ return defaultProviderCall(req, IDENTITY_PATHS.inspectInvitation, { email: invitation.email });
52
+ }
53
+
54
+ async function provisionInvitation({ req, invitation, password, intent }) {
42
55
  const hook = ctx.config.auth?.provisionInvitation;
43
- if (typeof hook === "function") return hook({ req, invitation, password });
56
+ if (typeof hook === "function") return hook({ req, invitation, password, intent });
44
57
  return defaultProviderCall(req, IDENTITY_PATHS.provisionInvitation, {
45
58
  email: invitation.email,
46
59
  nome: invitation.name,
47
60
  roleCode: invitation.roleCode,
48
61
  password,
62
+ intent,
49
63
  invitationFingerprint: invitation.tokenFingerprint,
50
64
  });
51
65
  }
@@ -63,6 +77,7 @@ async function changeFirstAccessPassword({ req, user, password }) {
63
77
  module.exports = {
64
78
  IDENTITY_PATHS,
65
79
  defaultProviderCall,
80
+ inspectInvitation,
66
81
  provisionInvitation,
67
82
  changeFirstAccessPassword,
68
83
  };
@@ -105,10 +105,10 @@ function sanitizeSnapshot(value) {
105
105
  return plain;
106
106
  }
107
107
 
108
- async function audit(event, { actor, target, before, after, context } = {}) {
108
+ async function audit(event, { actor, target, before, after, context, session } = {}) {
109
109
  if (mongoose.connection.readyState !== 1) return null;
110
110
  const { Audit } = models();
111
- return Audit.create({
111
+ const data = {
112
112
  appCode: appCode(),
113
113
  event,
114
114
  actor: typeof actor === "string" ? actor : actorKey(actor),
@@ -117,7 +117,8 @@ async function audit(event, { actor, target, before, after, context } = {}) {
117
117
  after: sanitizeSnapshot(after),
118
118
  context,
119
119
  at: new Date(),
120
- });
120
+ };
121
+ return session ? (await Audit.create([data], { session }))[0] : Audit.create(data);
121
122
  }
122
123
 
123
124
  function accessError(message, code = "RBAC_ACCESS_DENIED", statusCode = 403) {
@@ -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
  }