@oondemand/oon-core-back 0.7.12 → 0.7.14

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
@@ -13,7 +13,7 @@ O Core suporta `ecosystem.role` em `central.config.js`: `root` para a Central de
13
13
  ## Variáveis de ambiente
14
14
 
15
15
  - `CENTRAL_ATIVACAO_URL`: URL pública/frontend da Central de Ativações.
16
- - `CENTRAL_ATIVACAO_API_URL`: URL canônica do backend da Central de Ativações. Padrão: `https://central-ativacao.central.oondemand.online/api/`.
16
+ - `CENTRAL_ATIVACAO_API_URL`: URL canônica do backend da Central de Ativações. Padrão: `https://central-ativacao.oonapps.online/api/`.
17
17
  - `APP_CODE`: código do aplicativo no Ecossistema.
18
18
  - `APP_ENVIRONMENT`: `desenvolvimento`, `homologacao` ou `producao`.
19
19
  - `PUBLIC_APP_URL`: URL pública confirmada da Central.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -75,14 +75,42 @@ function addDataScopeFields(schema, definition) {
75
75
  return schema;
76
76
  }
77
77
 
78
+ // Natural-key uniqueness follows the same data scope as CRUD. This only
79
+ // declares indexes for new schemas; it never drops or migrates existing indexes.
80
+ function buildScopedSchema(definition) {
81
+ const scope = dataScope(definition), prefix = {};
82
+ if (scope === "tenant" || scope === "user" && (ctx.config.tenancyModel || ctx.config.ecosystem?.tenancyModel) !== "none") prefix[tenantField(definition)] = 1;
83
+ if (scope === "user") prefix[userField(definition)] = 1;
84
+ if (!Object.keys(prefix).length) return buildSchema(definition.schema, definition.options);
85
+ const shape = {}, indexes = [];
86
+ for (const [field, descriptor] of Object.entries(definition.schema)) {
87
+ if (descriptor && typeof descriptor === "object" && (descriptor.unique === true || descriptor.index?.unique === true)) {
88
+ const { unique, index, sparse, ...rest } = descriptor;
89
+ // Retain partial/collation/sparse semantics on the compound index.
90
+ const options = { ...(index && typeof index === "object" ? index : {}), unique: true, ...(sparse !== undefined ? { sparse } : {}) };
91
+ // A field-level name may belong to an existing global index. Use the
92
+ // generated compound name so adoption can retain and review both indexes.
93
+ delete options.name;
94
+ if (options.sparse) {
95
+ delete options.sparse;
96
+ const exists = { [field]: { $exists: true } };
97
+ options.partialFilterExpression = options.partialFilterExpression
98
+ ? { $and: [options.partialFilterExpression, exists] } : exists;
99
+ }
100
+ shape[field] = rest;
101
+ indexes.push([{ ...prefix, [field]: 1 }, options]);
102
+ } else shape[field] = descriptor;
103
+ }
104
+ const result = buildSchema(shape, definition.options);
105
+ for (const [keys, options] of indexes) result.schema.index(keys, options);
106
+ return result;
107
+ }
108
+
78
109
  function defineModel(definition) {
79
110
  if (!definition?.name) throw new Error("defineModel requer `name`.");
80
111
  if (!definition?.schema) throw new Error(`defineModel("${definition.name}") requer \`schema\`.`);
81
112
 
82
- const { schema, metaFields, searchableFields } = buildSchema(
83
- definition.schema,
84
- definition.options
85
- );
113
+ const { schema, metaFields, searchableFields } = buildScopedSchema(definition);
86
114
  // Campos de isolamento são internos: fazem parte do schema e dos índices,
87
115
  // mas não vazam para metadata/formulários declarativos.
88
116
  addDataScopeFields(schema, definition);
@@ -127,7 +127,7 @@ function createCrudController(entry, service) {
127
127
 
128
128
  async function createOne(data, method, session, accessContext) {
129
129
  const prepared = await prepareCreate(data, method, session, accessContext);
130
- await assertAtomicInvariants(entry, prepared.consolidated, null, session);
130
+ await assertAtomicInvariants(entry, prepared.consolidated, null, session, accessContext);
131
131
  const record = await service.create(prepared.changes, { session, accessContext });
132
132
  await recalculateDependents(entry, null, toPlainRecord(record), {
133
133
  session,
@@ -139,7 +139,7 @@ function createCrudController(entry, service) {
139
139
 
140
140
  async function updateOne(id, changes, method, session, accessContext) {
141
141
  const prepared = await prepareUpdate(id, changes, method, session, accessContext);
142
- await assertAtomicInvariants(entry, prepared.consolidated, prepared.current, session);
142
+ await assertAtomicInvariants(entry, prepared.consolidated, prepared.current, session, accessContext);
143
143
  const record = await service.update(id, prepared.changes, { session, accessContext });
144
144
  await recalculateDependents(entry, prepared.current, toPlainRecord(record), {
145
145
  session,
@@ -2,9 +2,9 @@ const GenericError = require("../errors/GenericError");
2
2
  const ctx = require("../context");
3
3
 
4
4
  const DEFAULT_CENTRAL_ATIVACAO_URL =
5
- "https://central-ativacao.central.oondemand.online";
5
+ "https://central-ativacao.oonapps.online";
6
6
  const DEFAULT_CENTRAL_ATIVACAO_API_URL =
7
- "https://central-ativacao.central.oondemand.online/api/";
7
+ "https://central-ativacao.oonapps.online/api/";
8
8
  const LOGIN_PATH = "/integracao/autenticacao-aplicativos/autenticar";
9
9
  const PERMISSION_PATH = "/integracao/autenticacao-aplicativos/verificar-permissao";
10
10
 
@@ -3,7 +3,7 @@
3
3
  const path = require("node:path");
4
4
  const { validateDeployFile } = require("../deploy/contracts/deploy-contract");
5
5
 
6
- const DEFAULT_ACTIVATION_API = "https://central-ativacao.central.oondemand.online/api";
6
+ const DEFAULT_ACTIVATION_API = "https://central-ativacao.oonapps.online/api";
7
7
 
8
8
  async function githubOidcToken(audience) {
9
9
  const base = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
@@ -2,6 +2,7 @@
2
2
 
3
3
  const mongoose = require("../core/database/mongoose");
4
4
  const registry = require("../core/registry");
5
+ const { mergeScopedFilter, scopedIdFilter, scopeFilter } = require("../core/tenancy");
5
6
  const {
6
7
  DomainRuleError,
7
8
  applyDomainMutation,
@@ -13,6 +14,7 @@ const {
13
14
  const MAX_AUTOMATIC_TRANSITIONS = 8;
14
15
  const MAX_TRANSACTION_ATTEMPTS = 3;
15
16
  let asynchronousQueue = Promise.resolve();
17
+ const transactionJobs = new WeakMap();
16
18
 
17
19
  function toPlainRecord(record) {
18
20
  if (!record || typeof record !== "object") return {};
@@ -182,20 +184,20 @@ function bindingDefault(binding) {
182
184
  return binding.kind === "aggregate" ? 0 : null;
183
185
  }
184
186
 
185
- async function loadLookupValues(binding, states, session) {
187
+ async function loadLookupValues(binding, states, session, accessContext) {
186
188
  const sourceEntry = registry.getModel(binding.sourceModel);
187
189
  if (!sourceEntry) throw new Error(`Model ${binding.sourceModel} não registrada.`);
188
190
  const ids = [...new Set(states.map((state) => idKey(state[binding.localField])).filter(Boolean))];
189
191
  if (!ids.length) return new Map();
190
192
  let query = sourceEntry.mongooseModel
191
- .find({ _id: { $in: ids } })
193
+ .find(mergeScopedFilter(sourceEntry, { _id: { $in: ids } }, accessContext))
192
194
  .select(`_id ${binding.sourceField}`);
193
195
  query = queryWithSession(query, session);
194
196
  const rows = await query.lean();
195
197
  return new Map(rows.map((row) => [idKey(row._id), row[binding.sourceField]]));
196
198
  }
197
199
 
198
- async function loadAggregateValues(binding, states, session) {
200
+ async function loadAggregateValues(binding, states, session, accessContext) {
199
201
  const sourceEntry = registry.getModel(binding.sourceModel);
200
202
  if (!sourceEntry) throw new Error(`Model ${binding.sourceModel} não registrada.`);
201
203
  const parentIds = [...new Map(
@@ -216,7 +218,7 @@ async function loadAggregateValues(binding, states, session) {
216
218
  default: accumulator = { $sum: { $ifNull: [`$${binding.sourceField}`, 0] } }; break;
217
219
  }
218
220
  let aggregate = sourceEntry.mongooseModel.aggregate([
219
- { $match: match },
221
+ { $match: mergeScopedFilter(sourceEntry, match, accessContext) },
220
222
  { $group: { _id: `$${binding.foreignField}`, value: accumulator } },
221
223
  ]);
222
224
  if (session && typeof aggregate.session === "function") aggregate = aggregate.session(session);
@@ -231,7 +233,7 @@ async function resolveBindingsForRecords(entry, records, options = {}) {
231
233
 
232
234
  for (const binding of bindings) {
233
235
  if (binding.kind === "lookup") {
234
- const values = await loadLookupValues(binding, states, options.session);
236
+ const values = await loadLookupValues(binding, states, options.session, options.accessContext);
235
237
  states.forEach((state, index) => {
236
238
  const value = values.has(idKey(state[binding.localField]))
237
239
  ? values.get(idKey(state[binding.localField]))
@@ -243,7 +245,7 @@ async function resolveBindingsForRecords(entry, records, options = {}) {
243
245
  }
244
246
 
245
247
  if (binding.kind === "aggregate") {
246
- const values = await loadAggregateValues(binding, states, options.session);
248
+ const values = await loadAggregateValues(binding, states, options.session, options.accessContext);
247
249
  states.forEach((state, index) => {
248
250
  const value = values.has(idKey(state._id))
249
251
  ? values.get(idKey(state._id))
@@ -341,7 +343,7 @@ function invariantError(rule, total, limit) {
341
343
  });
342
344
  }
343
345
 
344
- async function assertAtomicInvariants(entry, candidate, current, session) {
346
+ async function assertAtomicInvariants(entry, candidate, current, session, accessContext) {
345
347
  for (const rule of entry.definition?.processRules?.atomicInvariants || []) {
346
348
  if (rule.kind !== "relatedSumLteParentField") continue;
347
349
  const parentId = candidate?.[rule.parentLocalField];
@@ -350,7 +352,7 @@ async function assertAtomicInvariants(entry, candidate, current, session) {
350
352
  if (!parentEntry) throw new Error(`Model pai ${rule.parentModel} não registrada.`);
351
353
 
352
354
  let parentQuery = parentEntry.mongooseModel.findOneAndUpdate(
353
- { _id: parentId },
355
+ scopedIdFilter(parentEntry, parentId, accessContext),
354
356
  { $inc: { __oonInvariantVersion: 1 } },
355
357
  { new: true, session },
356
358
  );
@@ -372,7 +374,7 @@ async function assertAtomicInvariants(entry, candidate, current, session) {
372
374
  };
373
375
  if (current?._id) match._id = { $ne: current._id };
374
376
  let aggregate = entry.mongooseModel.aggregate([
375
- { $match: match },
377
+ { $match: mergeScopedFilter(entry, match, accessContext) },
376
378
  { $group: { _id: null, total: { $sum: { $ifNull: [`$${rule.sourceField}`, 0] } } } },
377
379
  ]);
378
380
  if (session && typeof aggregate.session === "function") aggregate = aggregate.session(session);
@@ -400,10 +402,10 @@ async function assertDeleteAllowed(entry, id, options = {}) {
400
402
  for (const rule of entry.definition?.processRules?.deleteProtection || []) {
401
403
  const sourceEntry = registry.getModel(rule.sourceModel);
402
404
  if (!sourceEntry) throw new Error(`Model ${rule.sourceModel} não registrada.`);
403
- let query = sourceEntry.mongooseModel.exists({
405
+ let query = sourceEntry.mongooseModel.exists(mergeScopedFilter(sourceEntry, {
404
406
  [rule.foreignField]: id,
405
407
  ...compileMongoMatch(rule.match),
406
- });
408
+ }, options.accessContext));
407
409
  query = queryWithSession(query, options.session);
408
410
  if (await query) {
409
411
  throw new DomainRuleError(rule.message, {
@@ -436,15 +438,18 @@ async function withProcessTransaction(entry, work) {
436
438
  try {
437
439
  let result;
438
440
  await session.withTransaction(async () => {
441
+ transactionJobs.set(session, []);
439
442
  result = await work(session);
440
443
  }, {
441
444
  readConcern: { level: "snapshot" },
442
445
  writeConcern: { w: "majority" },
443
446
  });
447
+ for (const job of transactionJobs.get(session) || []) enqueueAsynchronousRecalculation(job.target, job.options);
444
448
  return result;
445
449
  } catch (error) {
446
450
  if (attempt === MAX_TRANSACTION_ATTEMPTS || !isTransientTransactionError(error)) throw error;
447
451
  } finally {
452
+ transactionJobs.delete(session);
448
453
  await session.endSession();
449
454
  }
450
455
  }
@@ -492,7 +497,7 @@ function collectDependentTargets(sourceEntry, before, after, changedFields) {
492
497
  async function recalculateTarget(entry, selectors, options = {}) {
493
498
  const clauses = selectors.map(({ field, value }) => ({ [field]: value }));
494
499
  if (!clauses.length) return { matched: 0, modified: 0 };
495
- let query = entry.mongooseModel.find(clauses.length === 1 ? clauses[0] : { $or: clauses });
500
+ let query = entry.mongooseModel.find(mergeScopedFilter(entry, clauses.length === 1 ? clauses[0] : { $or: clauses }, options.accessContext));
496
501
  query = queryWithSession(query, options.session);
497
502
  const documents = await query.lean();
498
503
  if (!documents.length) return { matched: 0, modified: 0 };
@@ -513,7 +518,7 @@ async function recalculateTarget(entry, selectors, options = {}) {
513
518
  if (Object.keys(changed).length) {
514
519
  operations.push({
515
520
  updateOne: {
516
- filter: { _id: documents[index]._id },
521
+ filter: scopedIdFilter(entry, documents[index]._id, options.accessContext),
517
522
  update: { $set: changed },
518
523
  // Only server-derived, validated changes reach this recalculation path.
519
524
  // readonly still rejects client writes in applyDomainMutation.
@@ -530,10 +535,14 @@ async function recalculateTarget(entry, selectors, options = {}) {
530
535
  return { matched: documents.length, modified: result.modifiedCount || 0 };
531
536
  }
532
537
 
533
- function enqueueAsynchronousRecalculation(target) {
538
+ function enqueueAsynchronousRecalculation(target, options) {
539
+ // Capture a value, never a mutable request/session or the next user's context.
540
+ const accessContext = options.accessContext ? Object.freeze({ ...options.accessContext,
541
+ permissions: Object.freeze([...(options.accessContext.permissions || [])]) }) : undefined;
542
+ scopeFilter(target.entry, accessContext);
534
543
  asynchronousQueue = asynchronousQueue
535
544
  .then(() => new Promise((resolve) => setImmediate(resolve)))
536
- .then(() => recalculateTarget(target.entry, target.selectors, { session: null }))
545
+ .then(() => recalculateTarget(target.entry, target.selectors, { session: null, accessContext }))
537
546
  .catch((error) => {
538
547
  // A fila não pode morrer silenciosamente nem bloquear novas tarefas.
539
548
  console.error("OON_PROCESS_ASYNC_RECALCULATION_FAILED", {
@@ -556,7 +565,12 @@ async function recalculateDependents(entry, before, after, options = {}) {
556
565
  const results = [];
557
566
  for (const target of targets) {
558
567
  if (target.mode === "async") {
559
- enqueueAsynchronousRecalculation(target);
568
+ if (options.session?.inTransaction()) {
569
+ const jobs = transactionJobs.get(options.session);
570
+ if (!jobs) throw new DomainRuleError("Fila assíncrona exige transação gerenciada.", { code: "OON_PROCESS_TRANSACTION_REQUIRED", statusCode: 409 });
571
+ scopeFilter(target.entry, options.accessContext);
572
+ jobs.push({ target, options: { accessContext: options.accessContext ? { ...options.accessContext } : undefined } });
573
+ } else enqueueAsynchronousRecalculation(target, options);
560
574
  results.push({ model: target.entry.name, mode: "async" });
561
575
  } else {
562
576
  results.push({
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const registry = require("../core/registry");
4
+ const { mergeScopedFilter } = require("../core/tenancy");
4
5
  const { DomainRuleError, valuesEquivalent } = require("./domainRules");
5
6
 
6
7
  function idKey(value) {
@@ -83,7 +84,7 @@ async function assertReferencePolicies(entry, candidate, options = {}) {
83
84
 
84
85
  for (const group of grouped.values()) {
85
86
  let query = group.entry.mongooseModel
86
- .find({ _id: { $in: [...group.ids.values()] } })
87
+ .find(mergeScopedFilter(group.entry, { _id: { $in: [...group.ids.values()] } }, options.accessContext))
87
88
  .select(requiredProjection(group.rules.map((item) => item.rule)));
88
89
  query = queryWithSession(query, options.session);
89
90
  const records = await query.lean();